/**
 * <folk-campaign-planner> — n8n-style campaign flow canvas.
 *
 * Renders campaign posts, threads, platforms, audiences as draggable nodes
 * on an SVG canvas with ports, wiring, edges, inline config, and local-first
 * persistence via Automerge.
 *
 * Attributes:
 *   space — space slug (default "demo")
 */

import type {
	CampaignNodeType,
	CampaignPlannerNode,
	CampaignEdge,
	CampaignEdgeType,
	CampaignFlow,
	PostNodeData,
	ThreadNodeData,
	PlatformNodeData,
	AudienceNodeData,
	PhaseNodeData,
	GoalNodeData,
	MessageNodeData,
	ToneNodeData,
	BriefNodeData,
} from '../schemas';
import { SocialsLocalFirstClient } from '../local-first-client';
import { buildDemoCampaignFlow, PLATFORM_ICONS, PLATFORM_COLORS } from '../campaign-data';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';

// ── Port definitions ──

interface PortDef {
	kind: string;
	dir: 'in' | 'out';
	xFrac: number;
	yFrac: number;
	color: string;
	connectsTo?: string[];
}

const CAMPAIGN_PORT_DEFS: Record<CampaignNodeType, PortDef[]> = {
	post: [
		{ kind: 'publish', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#10b981', connectsTo: ['content-in', 'target-in'] },
		{ kind: 'sequence-out', dir: 'out', xFrac: 0.5, yFrac: 1.0, color: '#8b5cf6', connectsTo: ['sequence-in'] },
		{ kind: 'sequence-in', dir: 'in', xFrac: 0.5, yFrac: 0.0, color: '#8b5cf6' },
		{ kind: 'feeds-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#6366f1' },
	],
	thread: [
		{ kind: 'publish', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#10b981', connectsTo: ['content-in', 'target-in'] },
		{ kind: 'sequence-out', dir: 'out', xFrac: 0.5, yFrac: 1.0, color: '#8b5cf6', connectsTo: ['sequence-in'] },
		{ kind: 'sequence-in', dir: 'in', xFrac: 0.5, yFrac: 0.0, color: '#8b5cf6' },
		{ kind: 'feeds-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#6366f1' },
	],
	platform: [
		{ kind: 'content-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#10b981' },
	],
	audience: [
		{ kind: 'target-in', dir: 'in', xFrac: 0.0, yFrac: 0.5, color: '#f59e0b' },
	],
	phase: [],
	goal: [
		{ kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#6366f1', connectsTo: ['feeds-in'] },
	],
	message: [
		{ kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#6366f1', connectsTo: ['feeds-in'] },
	],
	tone: [
		{ kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#6366f1', connectsTo: ['feeds-in'] },
	],
	brief: [
		{ kind: 'feeds-out', dir: 'out', xFrac: 1.0, yFrac: 0.5, color: '#ec4899', connectsTo: ['feeds-in', 'sequence-in'] },
	],
};

// ── Edge type → visual config ──

const EDGE_STYLES: Record<CampaignEdgeType, { width: number; dash: string; animated: boolean }> = {
	publish: { width: 3, dash: '8 4', animated: true },
	sequence: { width: 2, dash: '', animated: false },
	target: { width: 2, dash: '4 6', animated: false },
	feeds: { width: 2, dash: '6 4', animated: false },
};

const EDGE_COLORS: Record<CampaignEdgeType, string> = {
	publish: '#10b981',
	sequence: '#8b5cf6',
	target: '#f59e0b',
	feeds: '#6366f1',
};

// ── Node sizes ──

function getNodeSize(node: CampaignPlannerNode): { w: number; h: number } {
	switch (node.type) {
		case 'post': return { w: 240, h: 120 };
		case 'thread': return { w: 240, h: 100 };
		case 'platform': return { w: 180, h: 80 };
		case 'audience': return { w: 180, h: 80 };
		case 'phase': {
			const d = node.data as PhaseNodeData;
			return { w: d.size.w, h: d.size.h };
		}
		case 'goal': return { w: 240, h: 130 };
		case 'message': return { w: 220, h: 100 };
		case 'tone': return { w: 220, h: 110 };
		case 'brief': return { w: 260, h: 150 };
		default: return { w: 200, h: 100 };
	}
}

function getPortDefs(type: CampaignNodeType): PortDef[] {
	return CAMPAIGN_PORT_DEFS[type] || [];
}

function getPortPosition(node: CampaignPlannerNode, portKind: string): { x: number; y: number } | null {
	const s = getNodeSize(node);
	const def = getPortDefs(node.type).find(p => p.kind === portKind);
	if (!def) return null;
	return { x: node.position.x + s.w * def.xFrac, y: node.position.y + s.h * def.yFrac };
}

// ── Helpers ──

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

function getUsername(): string | null {
	try {
		const raw = localStorage.getItem('encryptid:session');
		if (raw) {
			const s = JSON.parse(raw);
			return s.username || null;
		}
	} catch { /* ignore */ }
	return null;
}

// ── Component ──

class FolkCampaignPlanner extends HTMLElement {
	private shadow: ShadowRoot;
	private space = '';
	private currentView: 'canvas' | 'timeline' | 'platform' | 'table' = 'canvas';

	private get basePath() {
		const host = window.location.hostname;
		if (host.endsWith('.rspace.online') || host.endsWith('.rsocials.online')) {
			return '/rsocials/';
		}
		return `/${this.space}/rsocials/`;
	}

	// Data
	private nodes: CampaignPlannerNode[] = [];
	private edges: CampaignEdge[] = [];
	private currentFlowId = '';
	private flowName = '';

	// Canvas state
	private canvasZoom = 1;
	private canvasPanX = 0;
	private canvasPanY = 0;

	// Interaction state
	private isPanning = false;
	private panStartX = 0;
	private panStartY = 0;
	private panStartPanX = 0;
	private panStartPanY = 0;
	private draggingNodeId: string | null = null;
	private dragStartX = 0;
	private dragStartY = 0;
	private dragNodeStartX = 0;
	private dragNodeStartY = 0;
	private nodeDragStarted = false;

	// Selection
	private selectedNodeId: string | null = null;
	private selectedEdgeKey: string | null = null;
	private inlineEditNodeId: string | null = null;

	// Wiring
	private wiringActive = false;
	private wiringSourceNodeId: string | null = null;
	private wiringSourcePortKind: string | null = null;
	private wiringDragging = false;
	private wiringPointerX = 0;
	private wiringPointerY = 0;

	// Edge dragging
	private draggingEdgeKey: string | null = null;

	// Touch
	private isTouchPanning = false;
	private lastTouchCenter: { x: number; y: number } | null = null;
	private lastTouchDist: number | null = null;

	// Postiz
	private postizOpen = false;

	// Brief node generation state (keyed by node id)
	private briefLoading = new Set<string>();

	// Preview state for brief-generated nodes (not yet persisted)
	private previewNodeIds = new Set<string>();
	private previewEdgeIds = new Set<string>();
	private previewSourceBriefId = '';

	// Import modal state
	private importModalOpen = false;
	private importText = '';
	private importPlatform = 'x';

	// Explicit flow id requested via attribute (overrides active flow)
	private requestedFlowId = '';

	// Persistence
	private localFirstClient: SocialsLocalFirstClient | null = null;
	private saveTimer: ReturnType<typeof setTimeout> | null = null;
	private _lfcUnsub: (() => void) | null = null;
	private _stopPresence: (() => void) | null = null;

	// Context menu
	private contextMenuX = 0;
	private contextMenuY = 0;
	private contextMenuOpen = false;
	private contextMenuCanvasX = 0;
	private contextMenuCanvasY = 0;

	// Bound listeners (for cleanup)
	private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
	private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
	private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null;
	private _initialLoad = true;

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

	connectedCallback() {
		this.space = this.getAttribute('space') || 'demo';
		this.requestedFlowId = this.getAttribute('flow-id') || '';
		this.initData();
		this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rsocials', context: this.flowName || 'Social Campaigns' }));
	}

	disconnectedCallback() {
		this._stopPresence?.();
		if (this._lfcUnsub) this._lfcUnsub();
		if (this.saveTimer) clearTimeout(this.saveTimer);
		if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove);
		if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp);
		if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown);
		this.localFirstClient?.disconnect();
	}

	// ── Data init ──

	private async initData() {
		try {
			this.localFirstClient = new SocialsLocalFirstClient(this.space);
			await this.localFirstClient.init();
			await this.localFirstClient.subscribe();

			this._lfcUnsub = this.localFirstClient.onChange((doc) => {
				if (!this.currentFlowId || this.saveTimer) return;
				const flow = doc.campaignFlows?.[this.currentFlowId];
				if (flow) {
					// Preserve uncommitted preview nodes/edges when applying remote updates
					const previewNodes = this.nodes.filter(n => this.previewNodeIds.has(n.id));
					const previewEdges = this.edges.filter(e => this.previewEdgeIds.has(e.id));
					this.nodes = [
						...flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
						...previewNodes,
					];
					this.edges = [
						...flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined })),
						...previewEdges,
					];
					if (this.currentView === 'canvas') {
						this.drawCanvasContent();
					} else {
						this.switchView(this.currentView);
					}
				}
			});

			const activeId = this.localFirstClient.getActiveFlowId();
			const flows = this.localFirstClient.listCampaignFlows();

			if (this.requestedFlowId && this.localFirstClient.getCampaignFlow(this.requestedFlowId)) {
				this.loadFlow(this.requestedFlowId);
			} else if (activeId && this.localFirstClient.getCampaignFlow(activeId)) {
				this.loadFlow(activeId);
			} else if (flows.length > 0) {
				this.loadFlow(flows[0].id);
			} else {
				// No flows — create demo
				const demo = buildDemoCampaignFlow();
				const username = getUsername();
				if (username) demo.createdBy = `did:encryptid:${username}`;
				this.localFirstClient.saveCampaignFlow(demo);
				this.localFirstClient.setActiveFlow(demo.id);
				this.loadFlow(demo.id);
			}
		} catch (e) {
			console.warn('[CampaignPlanner] Local-first init failed, using demo data', e);
			// Drop the half-initialised client so executeSave doesn't write to a doc that was never opened.
			this.localFirstClient?.disconnect().catch(() => {});
			this.localFirstClient = null;
			const demo = buildDemoCampaignFlow();
			this.currentFlowId = demo.id;
			this.flowName = demo.name;
			this.nodes = demo.nodes;
			this.edges = demo.edges;
		}

		this.render();
		requestAnimationFrame(() => this.fitView());
	}

	private loadFlow(id: string) {
		const flow = this.localFirstClient?.getCampaignFlow(id);
		if (!flow) return;
		this.currentFlowId = id;
		this.flowName = flow.name;
		this.nodes = flow.nodes.map(n => ({ ...n, position: { ...n.position }, data: { ...n.data } }));
		this.edges = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined }));
		this.localFirstClient?.setActiveFlow(id);
		this.restoreViewport();
	}

	// ── Auto-save ──

	private scheduleSave() {
		if (this.saveTimer) clearTimeout(this.saveTimer);
		this.saveTimer = setTimeout(() => { this.executeSave(); this.saveTimer = null; }, 1500);
	}

	private executeSave() {
		if (this.localFirstClient && this.currentFlowId) {
			// Exclude preview-only nodes/edges from persistence until confirmed
			const committedNodes = this.previewNodeIds.size > 0
				? this.nodes.filter(n => !this.previewNodeIds.has(n.id))
				: this.nodes;
			const committedEdges = this.previewEdgeIds.size > 0
				? this.edges.filter(e => !this.previewEdgeIds.has(e.id))
				: this.edges;
			try {
				this.localFirstClient.updateFlowNodesEdges(this.currentFlowId, committedNodes, committedEdges);
			} catch (e) {
				// Doc may have been closed (disconnect race) — fall through to localStorage below
				console.warn('[CampaignPlanner] executeSave failed, persisting to localStorage', e);
				this.localFirstClient = null;
			}
		}
		if (!this.localFirstClient && this.currentFlowId) {
			localStorage.setItem(`rsocials:flow:${this.currentFlowId}`, JSON.stringify({
				id: this.currentFlowId, name: this.flowName,
				nodes: this.nodes, edges: this.edges,
				createdAt: Date.now(), updatedAt: Date.now(), createdBy: null,
			}));
		}
	}

	// ── Viewport persistence ──

	private saveViewport() {
		if (!this.currentFlowId) return;
		try {
			localStorage.setItem(`rsocials:vp:${this.currentFlowId}`, JSON.stringify({
				x: this.canvasPanX, y: this.canvasPanY, z: this.canvasZoom,
			}));
		} catch { /* ignore */ }
	}

	private restoreViewport() {
		if (!this.currentFlowId || this._initialLoad) return;
		try {
			const raw = localStorage.getItem(`rsocials:vp:${this.currentFlowId}`);
			if (raw) {
				const vp = JSON.parse(raw);
				this.canvasPanX = vp.x;
				this.canvasPanY = vp.y;
				this.canvasZoom = vp.z;
			}
		} catch { /* ignore */ }
	}

	// ── Canvas transform ──

	private updateCanvasTransform() {
		const g = this.shadow.getElementById('canvas-transform');
		if (g) g.setAttribute('transform', `translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})`);
		this.updateZoomDisplay();
		this.saveViewport();
	}

	private updateZoomDisplay() {
		const el = this.shadow.getElementById('zoom-level');
		if (el) el.textContent = `${Math.round(this.canvasZoom * 100)}%`;
	}

	private fitView(retries = 3) {
		const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null;
		if (!svg || this.nodes.length === 0) return;
		const rect = svg.getBoundingClientRect();
		if (rect.width === 0 || rect.height === 0) {
			if (retries > 0) requestAnimationFrame(() => this.fitView(retries - 1));
			return;
		}

		let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
		for (const n of this.nodes) {
			const s = getNodeSize(n);
			minX = Math.min(minX, n.position.x);
			minY = Math.min(minY, n.position.y);
			maxX = Math.max(maxX, n.position.x + s.w);
			maxY = Math.max(maxY, n.position.y + s.h);
		}

		const pad = 40;
		const contentW = maxX - minX + pad * 2;
		const contentH = maxY - minY + pad * 2;
		const scaleX = rect.width / contentW;
		const scaleY = rect.height / contentH;
		this.canvasZoom = Math.max(0.5, Math.min(scaleX, scaleY, 1.5));
		this.canvasPanX = (rect.width - contentW * this.canvasZoom) / 2 - (minX - pad) * this.canvasZoom;
		this.canvasPanY = (rect.height - contentH * this.canvasZoom) / 2 - (minY - pad) * this.canvasZoom;
		this.updateCanvasTransform();
		this._initialLoad = false;
	}

	private zoomAt(screenX: number, screenY: number, factor: number) {
		const oldZoom = this.canvasZoom;
		const newZoom = Math.max(0.1, Math.min(4, oldZoom * factor));
		this.canvasPanX = screenX - (screenX - this.canvasPanX) * (newZoom / oldZoom);
		this.canvasPanY = screenY - (screenY - this.canvasPanY) * (newZoom / oldZoom);
		this.canvasZoom = newZoom;
		this.updateCanvasTransform();
	}

	// ── Draw helpers (incremental updates) ──

	private drawCanvasContent() {
		const edgeLayer = this.shadow.getElementById('edge-layer');
		const nodeLayer = this.shadow.getElementById('node-layer');
		const wireLayer = this.shadow.getElementById('wire-layer');
		if (!edgeLayer || !nodeLayer) return;
		edgeLayer.innerHTML = this.renderAllEdges();
		nodeLayer.innerHTML = this.renderAllNodes();
		if (wireLayer) wireLayer.innerHTML = '';
		if (this.selectedNodeId) this.updateSelectionHighlight();
	}

	private redrawEdges() {
		const edgeLayer = this.shadow.getElementById('edge-layer');
		if (edgeLayer) edgeLayer.innerHTML = this.renderAllEdges();
	}

	private updateNodePosition(node: CampaignPlannerNode) {
		const nodeLayer = this.shadow.getElementById('node-layer');
		if (!nodeLayer) return;
		const g = nodeLayer.querySelector(`[data-node-id="${node.id}"]`) as SVGGElement | null;
		if (!g) return;
		const fo = g.querySelector('foreignObject');
		if (fo) {
			fo.setAttribute('x', String(node.position.x));
			fo.setAttribute('y', String(node.position.y));
		}
		// Phase nodes use rect instead
		const r = g.querySelector('rect.cp-phase-rect');
		if (r) {
			r.setAttribute('x', String(node.position.x));
			r.setAttribute('y', String(node.position.y));
		}
		// Update text positions for phase
		const texts = g.querySelectorAll('text');
		if (node.type === 'phase' && texts.length >= 2) {
			texts[0].setAttribute('x', String(node.position.x + 16));
			texts[0].setAttribute('y', String(node.position.y + 24));
			texts[1].setAttribute('x', String(node.position.x + 16));
			texts[1].setAttribute('y', String(node.position.y + 40));
		}
		// Update port positions
		const ports = g.querySelectorAll('.port-group');
		const s = getNodeSize(node);
		const defs = getPortDefs(node.type);
		ports.forEach((pg, i) => {
			if (i >= defs.length) return;
			const cx = node.position.x + s.w * defs[i].xFrac;
			const cy = node.position.y + s.h * defs[i].yFrac;
			const hit = pg.querySelector('.port-hit');
			const dot = pg.querySelector('.port-dot');
			const arrow = pg.querySelector('.port-arrow');
			if (hit) { hit.setAttribute('cx', String(cx)); hit.setAttribute('cy', String(cy)); }
			if (dot) { dot.setAttribute('cx', String(cx)); dot.setAttribute('cy', String(cy)); }
			if (arrow) {
				// Recalculate arrow path
				const def = defs[i];
				arrow.setAttribute('d', this.portArrowPath(cx, cy, def));
			}
		});
	}

	private portArrowPath(cx: number, cy: number, def: PortDef): string {
		if (def.xFrac === 0) {
			// Left port: arrow pointing left
			return `M ${cx + 2} ${cy - 3} l -5 3 l 5 3`;
		} else if (def.xFrac === 1) {
			// Right port: arrow pointing right
			return `M ${cx - 2} ${cy - 3} l 5 3 l -5 3`;
		} else if (def.dir === 'out') {
			// Bottom port: arrow pointing down
			return `M ${cx - 3} ${cy + 2} l 3 5 l 3 -5`;
		} else {
			// Top port: arrow pointing up
			return `M ${cx - 3} ${cy - 2} l 3 -5 l 3 5`;
		}
	}

	// ── Selection ──

	private updateSelectionHighlight() {
		const nodeLayer = this.shadow.getElementById('node-layer');
		if (!nodeLayer) return;
		nodeLayer.querySelectorAll('.cp-node').forEach(g => {
			g.classList.toggle('selected', g.getAttribute('data-node-id') === this.selectedNodeId);
		});
	}

	// ── Wiring ──

	private enterWiring(nodeId: string, portKind: string) {
		this.wiringActive = true;
		this.wiringSourceNodeId = nodeId;
		this.wiringSourcePortKind = portKind;
		this.wiringDragging = false;
		const canvas = this.shadow.getElementById('cp-canvas');
		if (canvas) canvas.classList.add('wiring');
		this.applyWiringClasses();
	}

	private cancelWiring() {
		this.wiringActive = false;
		this.wiringSourceNodeId = null;
		this.wiringSourcePortKind = null;
		this.wiringDragging = false;
		const canvas = this.shadow.getElementById('cp-canvas');
		if (canvas) canvas.classList.remove('wiring');
		const wireLayer = this.shadow.getElementById('wire-layer');
		if (wireLayer) wireLayer.innerHTML = '';
		this.clearWiringClasses();
	}

	private applyWiringClasses() {
		const nodeLayer = this.shadow.getElementById('node-layer');
		if (!nodeLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return;
		const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId);
		if (!sourceNode) return;
		const sourceDef = getPortDefs(sourceNode.type).find(p => p.kind === this.wiringSourcePortKind);
		const connectsTo = sourceDef?.connectsTo || [];

		nodeLayer.querySelectorAll('.port-group').forEach(pg => {
			const nid = pg.getAttribute('data-node-id');
			const kind = pg.getAttribute('data-port-kind');
			const dir = pg.getAttribute('data-port-dir');

			if (nid === this.wiringSourceNodeId && kind === this.wiringSourcePortKind) {
				pg.classList.add('port-group--wiring-source');
			} else if (dir === 'in' && connectsTo.includes(kind!) && nid !== this.wiringSourceNodeId) {
				// Check no existing edge
				const exists = this.edges.some(e => e.from === this.wiringSourceNodeId && e.to === nid);
				if (!exists) {
					pg.classList.add('port-group--wiring-target');
				} else {
					pg.classList.add('port-group--wiring-dimmed');
				}
			} else {
				pg.classList.add('port-group--wiring-dimmed');
			}
		});
	}

	private clearWiringClasses() {
		const nodeLayer = this.shadow.getElementById('node-layer');
		if (!nodeLayer) return;
		nodeLayer.querySelectorAll('.port-group').forEach(pg => {
			pg.classList.remove('port-group--wiring-source', 'port-group--wiring-target', 'port-group--wiring-dimmed');
		});
	}

	private completeWiring(targetNodeId: string) {
		if (!this.wiringSourceNodeId || !this.wiringSourcePortKind) return;
		const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId);
		const targetNode = this.nodes.find(n => n.id === targetNodeId);
		if (!sourceNode || !targetNode) { this.cancelWiring(); return; }

		// Determine edge type from port kinds
		let edgeType: CampaignEdgeType = 'publish';
		if (this.wiringSourcePortKind === 'feeds-out') {
			edgeType = 'feeds';
		} else if (this.wiringSourcePortKind === 'sequence-out') {
			edgeType = 'sequence';
		} else {
			// Check what kind of input the target has
			const targetPortKind = this.getWiringTargetPort(targetNode);
			if (targetPortKind === 'target-in') edgeType = 'target';
		}

		const edgeId = `e-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
		this.edges.push({
			id: edgeId,
			from: this.wiringSourceNodeId,
			to: targetNodeId,
			type: edgeType,
		});

		this.cancelWiring();
		this.drawCanvasContent();
		this.scheduleSave();
	}

	private getWiringTargetPort(targetNode: CampaignPlannerNode): string | null {
		if (!this.wiringSourcePortKind) return null;
		const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId);
		if (!sourceNode) return null;
		const sourceDef = getPortDefs(sourceNode.type).find(p => p.kind === this.wiringSourcePortKind);
		if (!sourceDef) return null;
		const targetDefs = getPortDefs(targetNode.type);
		for (const td of targetDefs) {
			if (td.dir === 'in' && sourceDef.connectsTo?.includes(td.kind)) return td.kind;
		}
		return null;
	}

	private updateWiringTempLine() {
		const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null;
		const wireLayer = this.shadow.getElementById('wire-layer');
		if (!svg || !wireLayer || !this.wiringSourceNodeId || !this.wiringSourcePortKind) return;

		const sourceNode = this.nodes.find(n => n.id === this.wiringSourceNodeId);
		if (!sourceNode) return;
		const portPos = getPortPosition(sourceNode, this.wiringSourcePortKind);
		if (!portPos) return;

		const rect = svg.getBoundingClientRect();
		const x2 = (this.wiringPointerX - rect.left - this.canvasPanX) / this.canvasZoom;
		const y2 = (this.wiringPointerY - rect.top - this.canvasPanY) / this.canvasZoom;
		const x1 = portPos.x;
		const y1 = portPos.y;

		// Bezier temp path
		const dx = x2 - x1;
		const dy = y2 - y1;
		const cx1 = x1 + dx * 0.4;
		const cy1 = y1;
		const cx2 = x2 - dx * 0.4;
		const cy2 = y2;
		const d = `M ${x1} ${y1} C ${cx1} ${cy1}, ${cx2} ${cy2}, ${x2} ${y2}`;
		wireLayer.innerHTML = `<path class="wiring-temp-path" d="${d}" fill="none"/>`;
	}

	// ── Edge waypoint dragging ──

	private setEdgeWaypoint(edgeKey: string, x: number, y: number) {
		const edge = this.edges.find(e => e.id === edgeKey);
		if (edge) {
			edge.waypoint = { x, y };
		}
	}

	// ── Node CRUD ──

	private addNode(type: CampaignNodeType, x: number, y: number) {
		const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
		let data: any;
		switch (type) {
			case 'post':
				data = { label: 'New Post', platform: 'x', postType: 'text', content: '', scheduledAt: '', status: 'draft', hashtags: [] };
				break;
			case 'thread':
				data = { label: 'New Thread', threadId: '', tweetCount: 0, status: 'draft', preview: '' };
				break;
			case 'platform':
				data = { label: 'Platform', platform: 'x', handle: '' };
				break;
			case 'audience':
				data = { label: 'Audience', description: '', sizeEstimate: '' };
				break;
			case 'phase':
				data = { label: 'New Phase', dateRange: '', color: '#6366f1', progress: 0, childNodeIds: [], size: { w: 400, h: 300 } };
				break;
			case 'goal':
				data = { label: 'Campaign Goal', objective: '', startDate: '', endDate: '' };
				break;
			case 'message':
				data = { label: 'Key Message', text: '', priority: 'medium' };
				break;
			case 'tone':
				data = { label: 'Tone & Style', tone: 'professional', style: 'awareness', audience: '', sizeEstimate: '' };
				break;
			case 'brief':
				data = { label: 'Campaign Brief', text: '', platforms: ['x', 'linkedin', 'instagram', 'threads', 'bluesky'] };
				break;
		}
		const node: CampaignPlannerNode = { id, type, position: { x, y }, data };
		this.nodes.push(node);
		this.drawCanvasContent();
		this.selectedNodeId = id;
		this.updateSelectionHighlight();
		this.scheduleSave();
	}

	private deleteNode(id: string) {
		this.nodes = this.nodes.filter(n => n.id !== id);
		this.edges = this.edges.filter(e => e.from !== id && e.to !== id);
		if (this.selectedNodeId === id) { this.selectedNodeId = null; this.exitInlineEdit(); }
		if (this.inlineEditNodeId === id) this.exitInlineEdit();
		this.drawCanvasContent();
		this.scheduleSave();
	}

	private deleteEdge(edgeId: string) {
		this.edges = this.edges.filter(e => e.id !== edgeId);
		this.selectedEdgeKey = null;
		this.redrawEdges();
		this.scheduleSave();
	}

	// ── Inline config panel ──

	private enterInlineEdit(nodeId: string) {
		if (this.inlineEditNodeId && this.inlineEditNodeId !== nodeId) this.exitInlineEdit();
		this.inlineEditNodeId = nodeId;
		this.selectedNodeId = nodeId;
		this.updateSelectionHighlight();

		const node = this.nodes.find(n => n.id === nodeId);
		if (!node || node.type === 'phase') return;

		const nodeLayer = this.shadow.getElementById('node-layer');
		const g = nodeLayer?.querySelector(`[data-node-id="${nodeId}"]`) as SVGGElement | null;
		if (!g) return;

		g.querySelector('.inline-edit-overlay')?.remove();

		const s = getNodeSize(node);
		const overlay = document.createElementNS('http://www.w3.org/2000/svg', 'g');
		overlay.classList.add('inline-edit-overlay');

		const panelW = 260;
		const panelH = node.type === 'post' ? 380 : node.type === 'thread' ? 240 : node.type === 'goal' ? 260 : node.type === 'tone' ? 300 : node.type === 'brief' ? 400 : 220;
		const panelX = s.w + 12;
		const panelY = 0;

		const fo = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
		fo.setAttribute('x', String(node.position.x + panelX));
		fo.setAttribute('y', String(node.position.y + panelY));
		fo.setAttribute('width', String(panelW));
		fo.setAttribute('height', String(panelH));

		const panelDiv = document.createElement('div');
		panelDiv.className = 'cp-inline-config';
		panelDiv.style.height = `${panelH}px`;
		panelDiv.innerHTML = this.renderInlineConfigContent(node);
		fo.appendChild(panelDiv);
		overlay.appendChild(fo);
		g.appendChild(overlay);

		this.attachInlineConfigListeners(g, node);

		// Click-outside handler
		const clickOutside = (e: Event) => {
			const target = e.target as Element;
			if (!target.closest(`[data-node-id="${node.id}"]`)) {
				this.exitInlineEdit();
				this.shadow.removeEventListener('pointerdown', clickOutside, true);
			}
		};
		setTimeout(() => {
			this.shadow.addEventListener('pointerdown', clickOutside, true);
		}, 100);
	}

	private exitInlineEdit() {
		if (!this.inlineEditNodeId) return;
		const nodeLayer = this.shadow.getElementById('node-layer');
		const g = nodeLayer?.querySelector(`[data-node-id="${this.inlineEditNodeId}"]`);
		g?.querySelector('.inline-edit-overlay')?.remove();
		this.inlineEditNodeId = null;
	}

	private renderInlineConfigContent(node: CampaignPlannerNode): string {
		const header = `<div class="cp-icp-header">${this.nodeIcon(node.type)} ${esc((node.data as any).label || node.type)}</div>`;
		let body = '';

		switch (node.type) {
			case 'post': {
				const d = node.data as PostNodeData;
				body = `
					<div class="cp-icp-body">
						<label>Content</label>
						<textarea data-field="content" rows="4">${esc(d.content)}</textarea>
						<label>Platform</label>
						<select data-field="platform">
							${['x', 'linkedin', 'instagram', 'youtube', 'threads', 'bluesky'].map(p =>
								`<option value="${p}" ${d.platform === p ? 'selected' : ''}>${p.charAt(0).toUpperCase() + p.slice(1)}</option>`
							).join('')}
						</select>
						<label>Post Type</label>
						<input data-field="postType" value="${esc(d.postType)}" placeholder="text, thread, carousel..."/>
						<label>Scheduled</label>
						<input type="datetime-local" data-field="scheduledAt" value="${d.scheduledAt}"/>
						<label>Status</label>
						<select data-field="status">
							<option value="draft" ${d.status === 'draft' ? 'selected' : ''}>Draft</option>
							<option value="scheduled" ${d.status === 'scheduled' ? 'selected' : ''}>Scheduled</option>
							<option value="published" ${d.status === 'published' ? 'selected' : ''}>Published</option>
						</select>
						<label>Hashtags (comma-separated)</label>
						<input data-field="hashtags" value="${esc(d.hashtags.join(', '))}"/>
					</div>`;
				break;
			}
			case 'thread': {
				const d = node.data as ThreadNodeData;
				const threads = this.localFirstClient?.listThreads() || [];
				const threadOptions = threads.length === 0
					? '<option value="">No threads yet</option>'
					: [`<option value="">— Select a thread —</option>`,
						...threads.map(t =>
							`<option value="${t.id}" ${d.threadId === t.id ? 'selected' : ''}>${esc(t.title || 'Untitled')} (${t.tweets.length} tweets)</option>`
						)].join('');
				const selectedThread = d.threadId ? threads.find(t => t.id === d.threadId) : null;
				const previewText = selectedThread?.tweets[0]
					? esc(selectedThread.tweets[0].substring(0, 120)) + (selectedThread.tweets[0].length > 120 ? '...' : '')
					: '';
				body = `
					<div class="cp-icp-body">
						<label>Thread</label>
						<select data-field="threadId" data-thread-picker="true">${threadOptions}</select>
						${previewText ? `<div style="font-size:11px;color:var(--text-secondary,#94a3b8);margin:4px 0 8px;line-height:1.4">${previewText}</div>` : ''}
						<label>Tweet Count</label>
						<input data-field="tweetCount" type="number" value="${d.tweetCount}" readonly/>
						<label>Status</label>
						<select data-field="status">
							<option value="draft" ${d.status === 'draft' ? 'selected' : ''}>Draft</option>
							<option value="ready" ${d.status === 'ready' ? 'selected' : ''}>Ready</option>
							<option value="published" ${d.status === 'published' ? 'selected' : ''}>Published</option>
						</select>
						<button class="cp-btn" data-action="open-thread" style="margin-top:8px;width:100%" ${!d.threadId ? 'disabled' : ''}>Open in Thread Editor</button>
					</div>`;
				break;
			}
			case 'platform': {
				const d = node.data as PlatformNodeData;
				body = `
					<div class="cp-icp-body">
						<label>Platform</label>
						<select data-field="platform">
							${['x', 'linkedin', 'instagram', 'youtube', 'threads', 'bluesky'].map(p =>
								`<option value="${p}" ${d.platform === p ? 'selected' : ''}>${p.charAt(0).toUpperCase() + p.slice(1)}</option>`
							).join('')}
						</select>
						<label>Handle</label>
						<input data-field="handle" value="${esc(d.handle)}" placeholder="@username"/>
					</div>`;
				break;
			}
			case 'audience': {
				const d = node.data as AudienceNodeData;
				body = `
					<div class="cp-icp-body">
						<label>Name</label>
						<input data-field="label" value="${esc(d.label)}"/>
						<label>Description</label>
						<textarea data-field="description" rows="2">${esc(d.description)}</textarea>
						<label>Size Estimate</label>
						<input data-field="sizeEstimate" value="${esc(d.sizeEstimate)}" placeholder="e.g. ~50K"/>
					</div>`;
				break;
			}
			case 'goal': {
				const d = node.data as GoalNodeData;
				body = `
					<div class="cp-icp-body">
						<label>Title</label>
						<input data-field="label" value="${esc(d.label)}"/>
						<label>Objective</label>
						<textarea data-field="objective" rows="3">${esc(d.objective)}</textarea>
						<label>Start Date</label>
						<input type="date" data-field="startDate" value="${d.startDate}"/>
						<label>End Date</label>
						<input type="date" data-field="endDate" value="${d.endDate}"/>
					</div>`;
				break;
			}
			case 'message': {
				const d = node.data as MessageNodeData;
				body = `
					<div class="cp-icp-body">
						<label>Message</label>
						<textarea data-field="text" rows="3">${esc(d.text)}</textarea>
						<label>Priority</label>
						<select data-field="priority">
							<option value="high" ${d.priority === 'high' ? 'selected' : ''}>High</option>
							<option value="medium" ${d.priority === 'medium' ? 'selected' : ''}>Medium</option>
							<option value="low" ${d.priority === 'low' ? 'selected' : ''}>Low</option>
						</select>
					</div>`;
				break;
			}
			case 'tone': {
				const d = node.data as ToneNodeData;
				body = `
					<div class="cp-icp-body">
						<label>Tone</label>
						<select data-field="tone">
							${['professional', 'casual', 'urgent', 'inspirational'].map(t =>
								`<option value="${t}" ${d.tone === t ? 'selected' : ''}>${t.charAt(0).toUpperCase() + t.slice(1)}</option>`
							).join('')}
						</select>
						<label>Style</label>
						<select data-field="style">
							${['event-promo', 'product-launch', 'awareness', 'community', 'educational'].map(s =>
								`<option value="${s}" ${d.style === s ? 'selected' : ''}>${s.replace('-', ' ')}</option>`
							).join('')}
						</select>
						<label>Audience</label>
						<textarea data-field="audience" rows="2">${esc(d.audience)}</textarea>
						<label>Audience Size</label>
						<input data-field="sizeEstimate" value="${esc(d.sizeEstimate)}" placeholder="e.g. ~50K"/>
					</div>`;
				break;
			}
			case 'brief': {
				const d = node.data as BriefNodeData;
				const loading = this.briefLoading.has(node.id);
				const plats = d.platforms || [];
				body = `
					<div class="cp-icp-body">
						<label>Campaign Brief</label>
						<textarea data-field="text" rows="6" placeholder="Describe your campaign: goal, audience, timeline, key messages...">${esc(d.text)}</textarea>
						<label>Platforms</label>
						<div class="cp-platform-checkboxes">
							${['x', 'linkedin', 'instagram', 'threads', 'bluesky', 'newsletter'].map(p =>
								`<label class="cp-platform-check">
									<input type="checkbox" data-brief-platform="${p}" ${plats.includes(p) ? 'checked' : ''}/>
									${p.charAt(0).toUpperCase() + p.slice(1)}
								</label>`
							).join('')}
						</div>
						<button class="cp-btn--generate" data-action="generate-brief" ${loading || !d.text.trim() ? 'disabled' : ''}>${loading ? 'Generating...' : '\u2728 Generate Campaign'}</button>
						${d.lastGeneratedAt ? `<div style="font-size:10px;color:var(--rs-text-muted);margin-top:6px">Last generated ${new Date(d.lastGeneratedAt).toLocaleString()}</div>` : ''}
					</div>`;
				break;
			}
		}

		// AI fill button for post/thread nodes
		const aiFillBtn = (node.type === 'post' || node.type === 'thread')
			? `<button class="cp-icp-btn-ai" data-action="ai-fill">&#x2728; AI Fill</button>` : '';

		const toolbar = `
			<div class="cp-icp-toolbar">
				${aiFillBtn}
				<button class="cp-icp-btn-done" data-action="done">Done</button>
				<button class="cp-icp-btn-delete" data-action="delete">Delete</button>
			</div>`;

		return header + body + toolbar;
	}

	private attachInlineConfigListeners(g: SVGGElement, node: CampaignPlannerNode) {
		const panel = g.querySelector('.cp-inline-config');
		if (!panel) return;

		// Field changes
		panel.querySelectorAll('[data-field]').forEach(el => {
			const field = el.getAttribute('data-field')!;
			const handler = () => {
				const val = (el as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).value;
				if (field === 'threadId' && (el as HTMLElement).hasAttribute('data-thread-picker')) {
					const d = node.data as ThreadNodeData;
					if (val) {
						const thread = this.localFirstClient?.listThreads().find(t => t.id === val);
						if (thread) {
							d.threadId = thread.id;
							d.label = thread.title || 'Untitled';
							d.tweetCount = thread.tweets.length;
							d.preview = thread.tweets[0]?.substring(0, 120) || '';
							d.status = 'draft';
						}
					} else {
						d.threadId = '';
						d.label = 'New Thread';
						d.tweetCount = 0;
						d.preview = '';
					}
					this.scheduleSave();
					this.drawCanvasContent();
					return;
				} else if (field === 'hashtags') {
					(node.data as PostNodeData).hashtags = val.split(',').map(h => h.trim()).filter(Boolean);
				} else if (field === 'tweetCount') {
					(node.data as ThreadNodeData).tweetCount = parseInt(val, 10) || 0;
				} else {
					(node.data as any)[field] = val;
				}
				// Update label for display
				if (field === 'content' && node.type === 'post') {
					(node.data as PostNodeData).label = val.split('\n')[0].substring(0, 40);
				}
				if (field === 'text' && node.type === 'message') {
					(node.data as MessageNodeData).label = val.substring(0, 40);
				}
				if (field === 'text' && node.type === 'brief') {
					const firstLine = val.split('\n')[0].substring(0, 40).trim();
					(node.data as BriefNodeData).label = firstLine || 'Campaign Brief';
					// Live-toggle the Generate button disabled state
					const genBtn = panel.querySelector('[data-action="generate-brief"]') as HTMLButtonElement | null;
					if (genBtn && !this.briefLoading.has(node.id)) genBtn.disabled = !val.trim();
				}
				// Mark downstream nodes stale when input nodes change
				if (node.type === 'goal' || node.type === 'message' || node.type === 'tone' || node.type === 'brief') {
					this.markDownstreamStale(node.id);
				}
				this.scheduleSave();
			};
			el.addEventListener('input', handler);
			el.addEventListener('change', handler);
		});

		// Brief platform checkboxes
		panel.querySelectorAll('[data-brief-platform]').forEach(cb => {
			cb.addEventListener('change', () => {
				const picked: string[] = [];
				panel.querySelectorAll('[data-brief-platform]:checked').forEach(c => {
					const name = (c as HTMLInputElement).getAttribute('data-brief-platform');
					if (name) picked.push(name);
				});
				(node.data as BriefNodeData).platforms = picked;
				this.scheduleSave();
			});
		});

		// Actions
		panel.querySelectorAll('[data-action]').forEach(el => {
			el.addEventListener('click', () => {
				const action = el.getAttribute('data-action');
				if (action === 'done') {
					this.exitInlineEdit();
					this.drawCanvasContent();
				} else if (action === 'delete') {
					this.exitInlineEdit();
					this.deleteNode(node.id);
				} else if (action === 'open-thread') {
					const d = node.data as ThreadNodeData;
					if (d.threadId) {
						window.location.href = `${this.basePath}thread-editor/${d.threadId}/edit`;
					}
				} else if (action === 'ai-fill') {
					this.aiFillNode(node.id);
				} else if (action === 'generate-brief') {
					this.generateFromBriefNode(node.id);
				}
			});
		});
	}

	private nodeIcon(type: CampaignNodeType): string {
		switch (type) {
			case 'post': return '<span style="font-size:14px">&#x1f4dd;</span>';
			case 'thread': return '<span style="font-size:14px">&#x1f9f5;</span>';
			case 'platform': return '<span style="font-size:14px">&#x1f4e1;</span>';
			case 'audience': return '<span style="font-size:14px">&#x1f3af;</span>';
			case 'phase': return '<span style="font-size:14px">&#x1f4c5;</span>';
			case 'goal': return '<span style="font-size:14px">&#x1f3af;</span>';
			case 'message': return '<span style="font-size:14px">&#x1f4ac;</span>';
			case 'tone': return '<span style="font-size:14px">&#x1f3ad;</span>';
			case 'brief': return '<span style="font-size:14px">&#x2728;</span>';
			default: return '';
		}
	}

	// ── Context menu ──

	private showContextMenu(screenX: number, screenY: number, canvasX: number, canvasY: number) {
		this.contextMenuX = screenX;
		this.contextMenuY = screenY;
		this.contextMenuCanvasX = canvasX;
		this.contextMenuCanvasY = canvasY;
		this.contextMenuOpen = true;

		let existing = this.shadow.getElementById('cp-context-menu');
		if (existing) existing.remove();

		const menu = document.createElement('div');
		menu.id = 'cp-context-menu';
		menu.className = 'cp-context-menu';
		menu.style.left = `${screenX}px`;
		menu.style.top = `${screenY}px`;

		const items = [
			{ icon: '\u{1f4dd}', label: 'Add Post', type: 'post' },
			{ icon: '\u{1f9f5}', label: 'Add Thread', type: 'thread' },
			{ icon: '\u{1f4e1}', label: 'Add Platform', type: 'platform' },
			{ icon: '\u{1f3af}', label: 'Add Audience', type: 'audience' },
			{ icon: '\u{1f4c5}', label: 'Add Phase', type: 'phase' },
			{ icon: '\u{1f3af}', label: 'Add Goal', type: 'goal' },
			{ icon: '\u{1f4ac}', label: 'Add Message', type: 'message' },
			{ icon: '\u{1f3ad}', label: 'Add Tone', type: 'tone' },
		];

		menu.innerHTML = items.map(it =>
			`<div class="cp-ctx-item" data-add-type="${it.type}">${it.icon} ${it.label}</div>`
		).join('') + (this.selectedNodeId ? `
			<div class="cp-ctx-sep"></div>
			<div class="cp-ctx-item" data-action="delete-selected" style="color:#ef4444">\u{1f5d1} Delete Selected</div>
		` : '');

		const canvasArea = this.shadow.querySelector('.cp-canvas-area');
		if (canvasArea) canvasArea.appendChild(menu);

		menu.querySelectorAll('[data-add-type]').forEach(el => {
			el.addEventListener('click', () => {
				this.addNode(el.getAttribute('data-add-type') as CampaignNodeType, canvasX, canvasY);
				this.closeContextMenu();
			});
		});

		menu.querySelector('[data-action="delete-selected"]')?.addEventListener('click', () => {
			if (this.selectedNodeId) this.deleteNode(this.selectedNodeId);
			this.closeContextMenu();
		});

		// Close on next click
		const close = () => { this.closeContextMenu(); document.removeEventListener('pointerdown', close); };
		setTimeout(() => document.addEventListener('pointerdown', close), 50);
	}

	private closeContextMenu() {
		this.contextMenuOpen = false;
		this.shadow.getElementById('cp-context-menu')?.remove();
	}

	// ── View switching ──

	private switchView(view: 'canvas' | 'timeline' | 'platform' | 'table') {
		this.currentView = view;
		const canvasEl = this.shadow.getElementById('cp-canvas');
		const altEl = this.shadow.getElementById('cp-alt-view');
		const zoomControls = this.shadow.querySelector('.cp-zoom-controls') as HTMLElement | null;

		// Update active button
		this.shadow.querySelectorAll('.cp-view-btn').forEach(btn => {
			btn.classList.toggle('active', btn.getAttribute('data-view') === view);
		});

		if (view === 'canvas') {
			if (canvasEl) canvasEl.style.display = '';
			if (altEl) { altEl.innerHTML = ''; altEl.style.display = 'none'; }
			if (zoomControls) zoomControls.style.display = '';
			return;
		}

		// Hide canvas SVG (preserves state)
		if (canvasEl) canvasEl.style.display = 'none';
		if (zoomControls) zoomControls.style.display = 'none';
		if (!altEl) return;
		altEl.style.display = '';

		switch (view) {
			case 'timeline': altEl.innerHTML = this.renderTimeline(); break;
			case 'platform': altEl.innerHTML = this.renderPlatformView(); break;
			case 'table': altEl.innerHTML = this.renderTableView(); break;
		}
		this.attachAltViewListeners();
	}

	// ── Timeline view ──

	private renderTimeline(): string {
		const posts = this.nodes.filter(n => n.type === 'post' || n.type === 'thread');
		const phases = this.nodes.filter(n => n.type === 'phase');

		// Collect all dates; group posts by day
		const dated = posts.map(n => {
			const d = n.data as PostNodeData;
			const dt = d.scheduledAt ? new Date(d.scheduledAt) : null;
			return { node: n, date: dt };
		}).sort((a, b) => {
			if (!a.date && !b.date) return 0;
			if (!a.date) return 1;
			if (!b.date) return -1;
			return a.date.getTime() - b.date.getTime();
		});

		// Build day buckets
		const dayMap = new Map<string, typeof dated>();
		for (const item of dated) {
			const key = item.date ? item.date.toISOString().split('T')[0] : 'unscheduled';
			if (!dayMap.has(key)) dayMap.set(key, []);
			dayMap.get(key)!.push(item);
		}

		// Determine date range for columns
		const allDates = dated.filter(d => d.date).map(d => d.date!);
		if (allDates.length === 0) {
			return '<div class="cp-timeline"><div style="padding:40px;color:var(--rs-text-secondary);text-align:center">No scheduled posts yet. Add dates in canvas view.</div></div>';
		}

		const minDate = new Date(Math.min(...allDates.map(d => d.getTime())));
		const maxDate = new Date(Math.max(...allDates.map(d => d.getTime())));
		minDate.setDate(minDate.getDate() - 1);
		maxDate.setDate(maxDate.getDate() + 1);

		const days: string[] = [];
		const cur = new Date(minDate);
		while (cur <= maxDate) {
			days.push(cur.toISOString().split('T')[0]);
			cur.setDate(cur.getDate() + 1);
		}

		// Phase bars
		let phaseBars = '';
		for (const phase of phases) {
			const pd = phase.data as PhaseNodeData;
			if (!pd.dateRange) continue;
			const parts = pd.dateRange.split(/\s*[-–]\s*/);
			if (parts.length < 2) continue;
			const start = days.indexOf(parts[0].trim());
			const end = days.indexOf(parts[1].trim());
			if (start < 0 || end < 0) continue;
			phaseBars += `<div class="cp-tl-phase" style="grid-column:${start + 1}/${end + 2};background:${pd.color}22;border:1px solid ${pd.color}44">
				<span style="color:${pd.color};font-weight:600;font-size:11px">${esc(pd.label)}</span>
			</div>`;
		}

		// Day headers + cards
		const headers = days.map(d => {
			const dt = new Date(d + 'T12:00:00');
			const label = dt.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
			return `<div class="cp-tl-day">${label}</div>`;
		}).join('');

		const columns = days.map(dayKey => {
			const items = dayMap.get(dayKey) || [];
			const cards = items.map(item => {
				const d = item.node.data as PostNodeData;
				const platform = d.platform || 'x';
				const color = PLATFORM_COLORS[platform] || '#888';
				const icon = PLATFORM_ICONS[platform] || platform.charAt(0);
				const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b';
				const time = item.date ? item.date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) : '';
				return `<div class="cp-tl-card" data-nav-node="${item.node.id}">
					<span class="cp-tl-card__icon" style="color:${color}">${icon}</span>
					<span class="cp-tl-card__label">${esc(d.label || (d.content || '').split('\n')[0].substring(0, 30) || 'Post')}</span>
					<span class="cp-tl-card__dot" style="background:${statusColor}"></span>
					${time ? `<span class="cp-tl-card__time">${time}</span>` : ''}
				</div>`;
			}).join('');
			return `<div class="cp-tl-col">${cards}</div>`;
		}).join('');

		// Unscheduled section
		let unscheduledHtml = '';
		const unscheduled = dayMap.get('unscheduled');
		if (unscheduled && unscheduled.length > 0) {
			const cards = unscheduled.map(item => {
				const d = item.node.data as PostNodeData;
				const platform = d.platform || 'x';
				const color = PLATFORM_COLORS[platform] || '#888';
				const icon = PLATFORM_ICONS[platform] || platform.charAt(0);
				return `<div class="cp-tl-card" data-nav-node="${item.node.id}">
					<span class="cp-tl-card__icon" style="color:${color}">${icon}</span>
					<span class="cp-tl-card__label">${esc(d.label || 'Post')}</span>
					<span class="cp-tl-card__dot" style="background:#f59e0b"></span>
				</div>`;
			}).join('');
			unscheduledHtml = `<div style="padding:12px 16px;border-top:1px solid var(--rs-border)">
				<div style="font-size:11px;color:var(--rs-text-secondary);margin-bottom:8px;font-weight:600">Unscheduled</div>
				<div style="display:flex;gap:8px;flex-wrap:wrap">${cards}</div>
			</div>`;
		}

		return `<div class="cp-timeline">
			<div class="cp-tl-scroll">
				<div class="cp-tl-grid" style="grid-template-columns:repeat(${days.length},minmax(120px,1fr))">
					<div class="cp-tl-header">${headers}</div>
					<div class="cp-tl-phases" style="grid-column:1/-1">${phaseBars}</div>
					<div class="cp-tl-body" style="grid-template-columns:repeat(${days.length},minmax(120px,1fr))">${columns}</div>
				</div>
			</div>
			${unscheduledHtml}
		</div>`;
	}

	// ── Platform view ──

	private renderPlatformView(): string {
		const posts = this.nodes.filter(n => n.type === 'post');

		// Group by platform
		const platformMap = new Map<string, CampaignPlannerNode[]>();
		for (const node of posts) {
			const d = node.data as PostNodeData;
			const p = d.platform || 'x';
			if (!platformMap.has(p)) platformMap.set(p, []);
			platformMap.get(p)!.push(node);
		}

		// Sort each platform's posts by date
		platformMap.forEach(items => {
			items.sort((a, b) => {
				const da = (a.data as PostNodeData).scheduledAt;
				const db = (b.data as PostNodeData).scheduledAt;
				if (!da && !db) return 0;
				if (!da) return 1;
				if (!db) return -1;
				return new Date(da).getTime() - new Date(db).getTime();
			});
		});

		if (platformMap.size === 0) {
			return '<div class="cp-platform-view"><div style="padding:40px;color:var(--rs-text-secondary);text-align:center">No posts yet. Add posts in canvas view.</div></div>';
		}

		const columns = Array.from(platformMap.entries()).map(([platform, items]) => {
			const color = PLATFORM_COLORS[platform] || '#888';
			const icon = PLATFORM_ICONS[platform] || platform.charAt(0);

			const cards = items.map(node => {
				const d = node.data as PostNodeData;
				const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b';
				const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : 'Unscheduled';
				const preview = (d.content || '').split('\n')[0].substring(0, 60);
				const tags = d.hashtags.slice(0, 3).map(h =>
					`<span class="cp-pv-tag">${esc(h.startsWith('#') ? h : '#' + h)}</span>`
				).join('');

				return `<div class="cp-pv-card" data-nav-node="${node.id}">
					<div class="cp-pv-card__header">
						<span class="cp-pv-card__label">${esc(d.label || preview || 'Post')}</span>
						<span class="cp-pv-card__dot" style="background:${statusColor}" title="${d.status}"></span>
					</div>
					${preview ? `<div class="cp-pv-card__preview">${esc(preview)}</div>` : ''}
					<div class="cp-pv-card__meta">
						<span class="cp-pv-card__type">${esc(d.postType)}</span>
						<span class="cp-pv-card__date">${dateStr}</span>
					</div>
					${tags ? `<div class="cp-pv-card__tags">${tags}</div>` : ''}
				</div>`;
			}).join('');

			return `<div class="cp-pv-column">
				<div class="cp-pv-column__header" style="border-top:3px solid ${color}">
					<span style="color:${color};font-size:14px">${icon}</span>
					<span class="cp-pv-column__title">${platform.charAt(0).toUpperCase() + platform.slice(1)}</span>
					<span class="cp-pv-column__count">${items.length}</span>
				</div>
				<div class="cp-pv-column__body">${cards}</div>
			</div>`;
		}).join('');

		return `<div class="cp-platform-view">${columns}</div>`;
	}

	// ── Table view ──

	private renderTableView(): string {
		const posts = this.nodes.filter(n => n.type === 'post' || n.type === 'thread');

		posts.sort((a, b) => {
			const da = (a.data as PostNodeData).scheduledAt || '';
			const db = (b.data as PostNodeData).scheduledAt || '';
			if (!da && !db) return 0;
			if (!da) return 1;
			if (!db) return -1;
			return da.localeCompare(db);
		});

		if (posts.length === 0) {
			return '<div class="cp-table-view"><div style="padding:40px;color:var(--rs-text-secondary);text-align:center">No posts yet.</div></div>';
		}

		const rows = posts.map(node => {
			const d = node.data as PostNodeData;
			const platform = d.platform || 'x';
			const color = PLATFORM_COLORS[platform] || '#888';
			const icon = PLATFORM_ICONS[platform] || platform.charAt(0);
			const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b';
			const statusLabel = d.status ? d.status.charAt(0).toUpperCase() + d.status.slice(1) : 'Draft';
			const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
			const preview = (d.content || '').split('\n')[0].substring(0, 50);
			const tags = (d.hashtags || []).slice(0, 3).map(h =>
				`<span class="cp-tv-tag">${esc(h.startsWith('#') ? h : '#' + h)}</span>`
			).join('');

			return `<tr class="cp-tv-row" data-nav-node="${node.id}">
				<td><span class="cp-tv-dot" style="background:${statusColor}"></span> ${statusLabel}</td>
				<td><span style="color:${color};margin-right:4px">${icon}</span> ${platform.charAt(0).toUpperCase() + platform.slice(1)}</td>
				<td>${esc(d.postType || node.type)}</td>
				<td class="cp-tv-content">${esc(preview)}</td>
				<td>${dateStr}</td>
				<td>${tags}</td>
			</tr>`;
		}).join('');

		return `<div class="cp-table-view">
			<table class="cp-tv-table">
				<thead><tr>
					<th>Status</th><th>Platform</th><th>Type</th><th>Content</th><th>Scheduled</th><th>Hashtags</th>
				</tr></thead>
				<tbody>${rows}</tbody>
			</table>
		</div>`;
	}

	// ── Alt view click-to-navigate ──

	private attachAltViewListeners() {
		const altEl = this.shadow.getElementById('cp-alt-view');
		if (!altEl) return;
		altEl.querySelectorAll('[data-nav-node]').forEach(el => {
			el.addEventListener('click', () => {
				const nodeId = el.getAttribute('data-nav-node');
				if (!nodeId) return;
				this.navigateToCanvasNode(nodeId);
			});
		});
	}

	private navigateToCanvasNode(nodeId: string) {
		this.switchView('canvas');
		const node = this.nodes.find(n => n.id === nodeId);
		if (!node) return;

		this.selectedNodeId = nodeId;
		this.updateSelectionHighlight();

		// Center node in view
		const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null;
		if (svg) {
			const rect = svg.getBoundingClientRect();
			const s = getNodeSize(node);
			const cx = node.position.x + s.w / 2;
			const cy = node.position.y + s.h / 2;
			this.canvasPanX = rect.width / 2 - cx * this.canvasZoom;
			this.canvasPanY = rect.height / 2 - cy * this.canvasZoom;
			this.updateCanvasTransform();
		}

		this.enterInlineEdit(nodeId);
	}

	// ── Rendering ──

	private render() {
		const schedulerUrl = 'https://demo.rsocials.online';

		this.shadow.innerHTML = `
		<link rel="stylesheet" href="/modules/rsocials/campaign-planner.css">
		<div class="cp-root">
			<div class="cp-toolbar">
				<span class="cp-toolbar__title">
					&#x1f4e2; ${esc(this.flowName || 'Campaign Planner')}
					${this.space === 'demo' ? '<span class="cp-demo-badge">Demo</span>' : ''}
				</span>
				<div class="cp-view-switcher">
					<button class="cp-view-btn ${this.currentView === 'canvas' ? 'active' : ''}" data-view="canvas" title="Canvas">
						<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><circle cx="4" cy="4" r="2" fill="currentColor"/><circle cx="12" cy="4" r="2" fill="currentColor"/><circle cx="4" cy="12" r="2" fill="currentColor"/><circle cx="12" cy="12" r="2" fill="currentColor"/><line x1="6" y1="4" x2="10" y2="4" stroke="currentColor" stroke-width="1.2"/><line x1="4" y1="6" x2="4" y2="10" stroke="currentColor" stroke-width="1.2"/><line x1="6" y1="12" x2="10" y2="12" stroke="currentColor" stroke-width="1.2"/></svg>
					</button>
					<button class="cp-view-btn ${this.currentView === 'timeline' ? 'active' : ''}" data-view="timeline" title="Timeline">
						<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="3" width="4" height="10" rx="1" fill="currentColor" opacity="0.3"/><rect x="6" y="1" width="4" height="14" rx="1" fill="currentColor" opacity="0.5"/><rect x="11" y="5" width="4" height="8" rx="1" fill="currentColor" opacity="0.7"/></svg>
					</button>
					<button class="cp-view-btn ${this.currentView === 'platform' ? 'active' : ''}" data-view="platform" title="Platform">
						<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="4" height="14" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="6" y="1" width="4" height="10" rx="1" stroke="currentColor" stroke-width="1.2"/><rect x="11" y="1" width="4" height="12" rx="1" stroke="currentColor" stroke-width="1.2"/></svg>
					</button>
					<button class="cp-view-btn ${this.currentView === 'table' ? 'active' : ''}" data-view="table" title="Table">
						<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><rect x="1" y="1" width="14" height="3" rx="0.5" fill="currentColor" opacity="0.5"/><rect x="1" y="5.5" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.3"/><rect x="1" y="9" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.3"/><rect x="1" y="12.5" width="14" height="2" rx="0.5" fill="currentColor" opacity="0.3"/></svg>
					</button>
				</div>
				<div class="cp-toolbar__actions">
					<button class="cp-btn cp-btn--brief" id="add-brief">\u2728 + Brief</button>
					<button class="cp-btn cp-btn--add" id="open-import">\u{1f4e5} Import</button>
					<button class="cp-btn cp-btn--regen" id="regen-stale-btn" style="display:${this.getStaleCount() > 0 ? '' : 'none'}">\u26a0 Regen ${this.getStaleCount()} Stale</button>
					<button class="cp-btn cp-btn--add" id="add-post">+ Post</button>
					<button class="cp-btn cp-btn--add" id="add-platform">+ Platform</button>
					<button class="cp-btn cp-btn--add" id="add-audience">+ Audience</button>
					<button class="cp-btn cp-btn--postiz" id="toggle-postiz">Schedule in Postiz</button>
				</div>
			</div>

			${this.previewNodeIds.size > 0 ? `
				<div class="cp-preview-banner">
					<span class="cp-preview-banner__label">\u2728 AI-generated preview \u2014 ${this.previewNodeIds.size} node${this.previewNodeIds.size === 1 ? '' : 's'}</span>
					<div class="cp-preview-banner__actions">
						<button class="cp-btn cp-btn--primary" id="preview-keep">Keep</button>
						<button class="cp-btn cp-btn--brief" id="preview-regen">Regenerate</button>
						<button class="cp-btn cp-btn--add" id="preview-discard">Discard</button>
					</div>
				</div>
			` : ''}

			<div class="cp-canvas-area">
				<div class="cp-canvas" id="cp-canvas">
					<svg id="cp-svg" width="100%" height="100%">
						<defs>
							<marker id="arrow-sequence" viewBox="0 0 10 6" refX="9" refY="3" markerWidth="8" markerHeight="6" orient="auto-start-reverse">
								<path d="M 0 0 L 10 3 L 0 6 z" fill="#8b5cf6"/>
							</marker>
						</defs>
						<g id="canvas-transform" transform="translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})">
							<g id="edge-layer">${this.renderAllEdges()}</g>
							<g id="node-layer">${this.renderAllNodes()}</g>
							<g id="wire-layer"></g>
						</g>
					</svg>
					<div class="cp-zoom-controls">
						<button class="cp-zoom-btn" id="zoom-in" title="Zoom in">+</button>
						<span class="cp-zoom-level" id="zoom-level">${Math.round(this.canvasZoom * 100)}%</span>
						<button class="cp-zoom-btn" id="zoom-out" title="Zoom out">&minus;</button>
						<button class="cp-zoom-btn" id="zoom-fit" title="Fit to view (F)">&#x2922;</button>
					</div>
				</div>
				<div id="cp-alt-view" class="cp-alt-view"></div>

				<div class="cp-postiz-panel ${this.postizOpen ? 'open' : ''}" id="postiz-panel">
					<div class="cp-postiz-header">
						<span class="cp-postiz-title">Postiz \u2014 Post Scheduler</span>
						<button class="cp-postiz-close" id="close-postiz">\u2715</button>
					</div>
					<iframe class="cp-postiz-iframe" src="${this.postizOpen ? schedulerUrl : 'about:blank'}" title="Postiz"></iframe>
				</div>
			</div>

			<div class="cp-modal-overlay" id="import-modal" ${this.importModalOpen ? '' : 'hidden'}>
				<div class="cp-modal">
					<div class="cp-modal__header">
						<h3>Import Posts from Markdown</h3>
						<button class="cp-modal__close" id="import-close">\u00d7</button>
					</div>
					<p class="cp-modal__hint">Paste tweets or posts separated by <code>---</code> on its own line. Each becomes a post node wired to the selected platform.</p>
					<textarea class="cp-modal__textarea" id="import-text" placeholder="First tweet\n---\nSecond tweet\n---\nThird tweet">${esc(this.importText)}</textarea>
					<div class="cp-modal__row">
						<label class="cp-modal__label">Platform</label>
						<select class="cp-modal__select" id="import-platform">
							${['x', 'linkedin', 'instagram', 'youtube', 'threads', 'bluesky'].map(p =>
								`<option value="${p}" ${this.importPlatform === p ? 'selected' : ''}>${p.charAt(0).toUpperCase() + p.slice(1)}</option>`
							).join('')}
						</select>
						<button class="cp-btn cp-btn--primary" id="import-submit">Parse &amp; Add</button>
					</div>
				</div>
			</div>
		</div>
		`;

		this.attachListeners();
	}

	// ── Node rendering ──

	private renderAllNodes(): string {
		// Phases first (rendered behind)
		const phases = this.nodes.filter(n => n.type === 'phase').map(n => this.renderPhaseNode(n));
		const others = this.nodes.filter(n => n.type !== 'phase').map(n => this.renderContentNode(n));
		return phases.join('') + others.join('');
	}

	private renderPhaseNode(node: CampaignPlannerNode): string {
		const d = node.data as PhaseNodeData;
		const { x, y } = node.position;
		const { w, h } = getNodeSize(node);
		const progress = d.progress || 0;
		const progressW = Math.max(0, Math.min(1, progress / 100)) * (w - 32);

		return `
		<g class="cp-node ${this.selectedNodeId === node.id ? 'selected' : ''}" data-node-id="${node.id}" data-node-type="phase">
			<rect class="cp-phase-rect" x="${x}" y="${y}" width="${w}" height="${h}"
				fill="${d.color}0a" stroke="${d.color}44"/>
			<text class="cp-phase-label" x="${x + 16}" y="${y + 24}">${esc(d.label)}</text>
			<text class="cp-phase-date" x="${x + 16}" y="${y + 40}">${esc(d.dateRange)}</text>
			<rect class="cp-phase-progress-bg" x="${x + 16}" y="${y + 50}" width="${w - 32}" height="4"/>
			<rect class="cp-phase-progress-fill" x="${x + 16}" y="${y + 50}" width="${progressW}" height="4" fill="${d.color}"/>
		</g>`;
	}

	private renderContentNode(node: CampaignPlannerNode): string {
		const { x, y } = node.position;
		const s = getNodeSize(node);
		let inner = '';

		switch (node.type) {
			case 'post': inner = this.renderPostNodeInner(node); break;
			case 'thread': inner = this.renderThreadNodeInner(node); break;
			case 'platform': inner = this.renderPlatformNodeInner(node); break;
			case 'audience': inner = this.renderAudienceNodeInner(node); break;
			case 'goal': inner = this.renderGoalNodeInner(node); break;
			case 'message': inner = this.renderMessageNodeInner(node); break;
			case 'tone': inner = this.renderToneNodeInner(node); break;
			case 'brief': inner = this.renderBriefNodeInner(node); break;
		}

		const ports = this.renderPortsSvg(node);
		const previewClass = this.previewNodeIds.has(node.id) ? ' cp-node--preview' : '';

		return `
		<g class="cp-node ${this.selectedNodeId === node.id ? 'selected' : ''}${previewClass}" data-node-id="${node.id}" data-node-type="${node.type}">
			<foreignObject x="${x}" y="${y}" width="${s.w}" height="${s.h}">
				${inner}
			</foreignObject>
			${ports}
		</g>`;
	}

	private renderPostNodeInner(node: CampaignPlannerNode): string {
		const d = node.data as PostNodeData;
		const platform = d.platform || 'x';
		const color = PLATFORM_COLORS[platform] || '#888';
		const icon = PLATFORM_ICONS[platform] || platform.charAt(0);
		const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'scheduled' ? '#3b82f6' : '#f59e0b';
		const preview = (d.content || '').split('\n')[0].substring(0, 50);
		const charCount = (d.content || '').length;
		const charMax = platform === 'x' ? 280 : 2200;
		const charPct = Math.min(1, charCount / charMax) * 100;
		const dateStr = d.scheduledAt ? new Date(d.scheduledAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : 'Unscheduled';
		const staleBadge = node.stale ? `<span style="display:inline-flex;align-items:center;gap:2px;padding:1px 5px;border-radius:3px;background:#f59e0b22;color:#f59e0b;font-size:8px;font-weight:600;flex-shrink:0">\u26a0 stale</span>` : '';

		return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:var(--rs-bg-surface);border:1px solid ${node.stale ? '#f59e0b55' : 'var(--rs-border)'};border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;display:flex">
			<div style="width:4px;background:${color};flex-shrink:0"></div>
			<div style="flex:1;padding:10px 12px;display:flex;flex-direction:column;gap:4px;min-width:0">
				<div style="display:flex;align-items:center;gap:6px">
					<span style="font-size:12px;width:20px;height:20px;border-radius:4px;background:${color}22;color:${color};display:flex;align-items:center;justify-content:center;font-weight:700;flex-shrink:0">${icon}</span>
					<span style="font-size:12px;font-weight:600;color:var(--rs-text-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.label || preview || 'New Post')}</span>
					${staleBadge}
					<span style="width:7px;height:7px;border-radius:50%;background:${statusColor};flex-shrink:0" title="${d.status}"></span>
				</div>
				<div style="font-size:10px;color:var(--rs-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(preview)}</div>
				<div style="display:flex;align-items:center;gap:6px;margin-top:auto">
					<div style="flex:1;height:3px;background:var(--rs-bg-surface-raised);border-radius:2px;overflow:hidden">
						<div style="width:${charPct}%;height:100%;background:${charPct > 90 ? '#ef4444' : color};border-radius:2px"></div>
					</div>
					<span style="font-size:9px;color:var(--rs-text-muted)">${charCount}/${charMax}</span>
				</div>
				<div style="font-size:9px;color:var(--rs-text-muted)">${dateStr}</div>
			</div>
		</div>`;
	}

	private renderThreadNodeInner(node: CampaignPlannerNode): string {
		const d = node.data as ThreadNodeData;
		const statusColor = d.status === 'published' ? '#22c55e' : d.status === 'ready' ? '#3b82f6' : '#f59e0b';

		return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:var(--rs-bg-surface);border:1px solid var(--rs-border);border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;padding:10px 14px;display:flex;flex-direction:column;gap:6px">
			<div style="display:flex;align-items:center;gap:6px">
				<span style="font-size:14px">&#x1f9f5;</span>
				<span style="font-size:12px;font-weight:600;color:var(--rs-text-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.label || 'Thread')}</span>
				<span style="width:7px;height:7px;border-radius:50%;background:${statusColor};flex-shrink:0" title="${d.status}"></span>
			</div>
			<div style="font-size:10px;color:var(--rs-text-secondary)">${d.tweetCount} tweet${d.tweetCount === 1 ? '' : 's'}</div>
			<div style="font-size:10px;color:var(--rs-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.preview || '')}</div>
		</div>`;
	}

	private renderPlatformNodeInner(node: CampaignPlannerNode): string {
		const d = node.data as PlatformNodeData;
		const platform = d.platform || 'x';
		const color = PLATFORM_COLORS[platform] || '#888';
		const icon = PLATFORM_ICONS[platform] || platform.charAt(0);
		const connectedCount = this.edges.filter(e => e.to === node.id && e.type === 'publish').length;

		return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:${color}0a;border:1px solid ${color}33;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;padding:12px 14px;display:flex;flex-direction:column;gap:4px">
			<div style="display:flex;align-items:center;gap:8px">
				<span style="font-size:18px;width:30px;height:30px;border-radius:6px;background:${color}22;color:${color};display:flex;align-items:center;justify-content:center;font-weight:700">${icon}</span>
				<div>
					<div style="font-size:12px;font-weight:600;color:var(--rs-text-primary)">${esc(d.label)}</div>
					<div style="font-size:10px;color:var(--rs-text-secondary)">${esc(d.handle)}</div>
				</div>
			</div>
			<div style="font-size:9px;color:var(--rs-text-muted);margin-top:auto">${connectedCount} post${connectedCount === 1 ? '' : 's'} connected</div>
		</div>`;
	}

	private renderAudienceNodeInner(node: CampaignPlannerNode): string {
		const d = node.data as AudienceNodeData;
		const connectedCount = this.edges.filter(e => e.to === node.id && e.type === 'target').length;

		return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#f59e0b0a;border:1px solid #f59e0b33;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;padding:12px 14px;display:flex;flex-direction:column;gap:4px">
			<div style="display:flex;align-items:center;gap:8px">
				<span style="font-size:16px">&#x1f3af;</span>
				<div>
					<div style="font-size:12px;font-weight:600;color:var(--rs-text-primary)">${esc(d.label)}</div>
					<div style="font-size:10px;color:var(--rs-text-secondary)">${esc(d.sizeEstimate)}</div>
				</div>
			</div>
			<div style="font-size:9px;color:var(--rs-text-muted)">${connectedCount} targeted \u00b7 ${esc(d.description).substring(0, 40)}</div>
		</div>`;
	}

	private renderGoalNodeInner(node: CampaignPlannerNode): string {
		const d = node.data as GoalNodeData;
		const staleBadge = node.stale ? '' : ''; // goals don't show stale
		return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#6366f10a;border:1px solid #6366f133;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;padding:12px 14px;display:flex;flex-direction:column;gap:4px">
			<div style="display:flex;align-items:center;gap:6px">
				<span style="font-size:16px">&#x1f3af;</span>
				<span style="font-size:12px;font-weight:600;color:var(--rs-text-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.label || 'Campaign Goal')}</span>
			</div>
			<div style="font-size:10px;color:var(--rs-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.objective || '').substring(0, 60)}</div>
			<div style="font-size:9px;color:var(--rs-text-muted);margin-top:auto">${d.startDate ? esc(d.startDate) + ' \u2192 ' + esc(d.endDate) : 'No dates set'}</div>
		</div>`;
	}

	private renderMessageNodeInner(node: CampaignPlannerNode): string {
		const d = node.data as MessageNodeData;
		const priorityColor = d.priority === 'high' ? '#ef4444' : d.priority === 'medium' ? '#f59e0b' : '#94a3b8';
		return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#6366f10a;border:1px solid #6366f133;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;display:flex">
			<div style="width:4px;background:${priorityColor};flex-shrink:0"></div>
			<div style="flex:1;padding:10px 12px;display:flex;flex-direction:column;gap:4px;min-width:0">
				<div style="display:flex;align-items:center;gap:6px">
					<span style="font-size:14px">&#x1f4ac;</span>
					<span style="font-size:12px;font-weight:600;color:var(--rs-text-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.label || 'Key Message')}</span>
					<span style="font-size:9px;padding:2px 6px;border-radius:4px;background:${priorityColor}22;color:${priorityColor};font-weight:600">${d.priority}</span>
				</div>
				<div style="font-size:10px;color:var(--rs-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.text || '').substring(0, 60)}</div>
			</div>
		</div>`;
	}

	private renderBriefNodeInner(node: CampaignPlannerNode): string {
		const d = node.data as BriefNodeData;
		const loading = this.briefLoading.has(node.id);
		const plats = d.platforms || [];
		const preview = (d.text || '').split('\n')[0].substring(0, 80);
		const platPills = plats.slice(0, 4).map(p => `<span style="font-size:9px;padding:1px 6px;border-radius:4px;background:#ec489922;color:#ec4899">${p}</span>`).join('');
		const extra = plats.length > 4 ? `<span style="font-size:9px;color:var(--rs-text-muted)">+${plats.length - 4}</span>` : '';
		return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#ec48990a;border:1px solid #ec489944;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;display:flex">
			<div style="width:4px;background:#ec4899;flex-shrink:0"></div>
			<div style="flex:1;padding:10px 12px;display:flex;flex-direction:column;gap:4px;min-width:0">
				<div style="display:flex;align-items:center;gap:6px">
					<span style="font-size:14px">&#x2728;</span>
					<span style="font-size:12px;font-weight:600;color:var(--rs-text-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.label || 'Campaign Brief')}</span>
					${loading ? '<span style="font-size:9px;padding:1px 6px;border-radius:4px;background:#ec489922;color:#ec4899">generating…</span>' : ''}
				</div>
				<div style="font-size:10px;color:var(--rs-text-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(preview) || '<em>Click to write a brief</em>'}</div>
				<div style="display:flex;align-items:center;gap:4px;flex-wrap:wrap;margin-top:auto">${platPills}${extra}</div>
			</div>
		</div>`;
	}

	private renderToneNodeInner(node: CampaignPlannerNode): string {
		const d = node.data as ToneNodeData;
		return `<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%;background:#8b5cf60a;border:1px solid #8b5cf633;border-radius:10px;overflow:hidden;font-family:system-ui,sans-serif;padding:12px 14px;display:flex;flex-direction:column;gap:4px">
			<div style="display:flex;align-items:center;gap:6px">
				<span style="font-size:16px">&#x1f3ad;</span>
				<span style="font-size:12px;font-weight:600;color:var(--rs-text-primary);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.label || 'Tone')}</span>
			</div>
			<div style="font-size:10px;color:var(--rs-text-secondary)">${esc(d.tone)} \u00b7 ${esc(d.style.replace('-', ' '))}</div>
			<div style="font-size:9px;color:var(--rs-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(d.audience || '').substring(0, 50)}</div>
		</div>`;
	}

	private renderStaleBadge(node: CampaignPlannerNode): string {
		if (!node.stale) return '';
		return `<span class="cp-stale-badge">\u26a0 stale</span>`;
	}

	// ── Stale tracking ──

	private markDownstreamStale(nodeId: string): boolean {
		// Returns true if any node was newly marked stale — callers redraw only then,
		// so typing in an input node doesn't destroy an open inline edit overlay.
		let changed = false;
		const outEdges = this.edges.filter(e => e.from === nodeId);
		for (const edge of outEdges) {
			const target = this.nodes.find(n => n.id === edge.to);
			if (!target) continue;
			if ((target.type === 'post' || target.type === 'thread') && !target.stale) {
				target.stale = true;
				target.staleReason = `Input node changed`;
				changed = true;
			}
			if (target.type === 'phase') {
				if (this.markDownstreamStale(target.id)) changed = true;
			}
		}
		if (changed) {
			this.drawCanvasContent();
			this.updateToolbarStaleCount();
		}
		return changed;
	}

	private getStaleCount(): number {
		return this.nodes.filter(n => n.stale).length;
	}

	// ── Brief-node generation ──

	private async generateFromBriefNode(briefNodeId: string, options?: { discardPrevious?: boolean }) {
		const briefNode = this.nodes.find(n => n.id === briefNodeId);
		if (!briefNode || briefNode.type !== 'brief') return;
		if (this.briefLoading.has(briefNodeId)) return;
		const d = briefNode.data as BriefNodeData;
		if (!d.text.trim()) return;

		if (options?.discardPrevious) this.discardPreviewInternal();

		this.briefLoading.add(briefNodeId);
		this.exitInlineEdit();
		this.enterInlineEdit(briefNodeId);
		this.drawCanvasContent();

		try {
			const res = await fetch(`${this.basePath}api/campaign/flow/from-brief`, {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({
					rawBrief: d.text,
					platforms: d.platforms,
				}),
			});

			if (!res.ok) {
				const err = await res.json().catch(() => ({}));
				throw new Error((err as any).error || `HTTP ${res.status}`);
			}

			const flow: CampaignFlow = await res.json();

			// Offset generated nodes to the right of the brief so the brief stays visible
			const offsetX = briefNode.position.x + 300;
			const offsetY = briefNode.position.y;
			const generated = flow.nodes.map(n => ({
				...n,
				position: { x: n.position.x + offsetX, y: n.position.y + offsetY },
				data: { ...n.data },
			}));
			d.lastGeneratedAt = Date.now();

			// brief → first goal (if present)
			const generatedEdges: CampaignEdge[] = flow.edges.map(e => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined }));
			const firstGoal = generated.find(n => n.type === 'goal');
			if (firstGoal) {
				generatedEdges.push({
					id: `e-brief-${Date.now()}-goal`,
					from: briefNode.id,
					to: firstGoal.id,
					type: 'feeds',
				});
			}

			// Append generated nodes/edges to the CURRENT flow as preview (not persisted)
			this.previewNodeIds = new Set(generated.map(n => n.id));
			this.previewEdgeIds = new Set(generatedEdges.map(e => e.id));
			this.previewSourceBriefId = briefNode.id;

			this.nodes = [...this.nodes, ...generated];
			this.edges = [...this.edges, ...generatedEdges];

			this.exitInlineEdit();
			this.render();
			requestAnimationFrame(() => this.fitView());
		} catch (e: any) {
			console.error('[CampaignPlanner] Generate from brief error:', e.message);
			alert('Failed to generate: ' + e.message);
		} finally {
			this.briefLoading.delete(briefNodeId);
			// Re-render to clear loading state in the brief node
			this.drawCanvasContent();
		}
	}

	// ── Preview actions ──

	private confirmPreview() {
		if (this.previewNodeIds.size === 0) return;
		this.previewNodeIds.clear();
		this.previewEdgeIds.clear();
		this.previewSourceBriefId = '';
		// Persist immediately (not debounced)
		if (this.localFirstClient && this.currentFlowId) {
			this.localFirstClient.updateFlowNodesEdges(this.currentFlowId, this.nodes, this.edges);
		}
		this.render();
	}

	private discardPreviewInternal() {
		if (this.previewNodeIds.size === 0 && this.previewEdgeIds.size === 0) return;
		this.nodes = this.nodes.filter(n => !this.previewNodeIds.has(n.id));
		this.edges = this.edges.filter(e => !this.previewEdgeIds.has(e.id));
		this.previewNodeIds.clear();
		this.previewEdgeIds.clear();
		this.previewSourceBriefId = '';
	}

	private discardPreview() {
		this.discardPreviewInternal();
		this.render();
	}

	private regeneratePreview() {
		const briefId = this.previewSourceBriefId;
		if (!briefId) return;
		this.generateFromBriefNode(briefId, { discardPrevious: true });
	}

	// ── Markdown import ──

	private openImportModal() {
		this.importModalOpen = true;
		this.render();
	}

	private closeImportModal() {
		this.importModalOpen = false;
		this.render();
	}

	private importFromMarkdown() {
		const raw = this.importText || '';
		const tweets = raw.split(/\n---\n/).map(t => t.trim()).filter(Boolean);
		if (tweets.length === 0) {
			alert('No posts to import — separate tweets with lines containing just ---');
			return;
		}

		const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null;
		const rect = svg?.getBoundingClientRect();
		const cx = rect ? (rect.width / 2 - this.canvasPanX) / this.canvasZoom : 300;
		const cy = rect ? (rect.height / 2 - this.canvasPanY) / this.canvasZoom : 200;

		const platform = this.importPlatform || 'x';

		// Find or create a platform node for this platform
		let platformNode = this.nodes.find(n =>
			n.type === 'platform' && (n.data as PlatformNodeData).platform === platform
		);
		const newNodes: CampaignPlannerNode[] = [];
		const newEdges: CampaignEdge[] = [];
		const now = Date.now();

		if (!platformNode) {
			const platId = `platform-${now}-${platform}`;
			platformNode = {
				id: platId,
				type: 'platform',
				position: { x: cx + 600, y: cy },
				data: { label: platform.charAt(0).toUpperCase() + platform.slice(1), platform, handle: '' } as PlatformNodeData,
			};
			newNodes.push(platformNode);
		}

		// Grid-position imported posts in a 3-wide column
		tweets.forEach((text, i) => {
			const postId = `post-${now}-${i}`;
			const col = i % 3;
			const row = Math.floor(i / 3);
			newNodes.push({
				id: postId,
				type: 'post',
				position: { x: cx + col * 260, y: cy + row * 140 },
				data: {
					label: text.split('\n')[0].substring(0, 40) || 'Imported post',
					platform,
					postType: 'text',
					content: text,
					scheduledAt: '',
					status: 'draft',
					hashtags: [],
				} as PostNodeData,
			});
			newEdges.push({
				id: `e-import-${now}-${i}`,
				from: postId,
				to: platformNode!.id,
				type: 'publish',
			});
		});

		this.nodes = [...this.nodes, ...newNodes];
		this.edges = [...this.edges, ...newEdges];

		this.importText = '';
		this.importModalOpen = false;
		this.render();
		this.scheduleSave();
		requestAnimationFrame(() => this.fitView());
	}

	private async regenStaleNodes() {
		const staleIds = this.nodes.filter(n => n.stale).map(n => n.id);
		if (staleIds.length === 0) return;

		try {
			const res = await fetch(`${this.basePath}api/campaign/flow/regen-nodes`, {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({
					flowId: this.currentFlowId,
					nodeIds: staleIds,
				}),
			});

			if (!res.ok) {
				const err = await res.json().catch(() => ({}));
				throw new Error((err as any).error || `HTTP ${res.status}`);
			}

			const data = await res.json();
			if (data.flow) {
				this.nodes = data.flow.nodes.map((n: any) => ({ ...n, position: { ...n.position }, data: { ...n.data } }));
				this.edges = data.flow.edges.map((e: any) => ({ ...e, waypoint: e.waypoint ? { ...e.waypoint } : undefined }));
				this.drawCanvasContent();
				this.updateToolbarStaleCount();
			}
		} catch (e: any) {
			console.error('[CampaignPlanner] Regen stale error:', e.message);
			alert('Failed to regenerate: ' + e.message);
		}
	}

	private async aiFillNode(nodeId: string) {
		try {
			const res = await fetch(`${this.basePath}api/campaign/flow/ai-fill-node`, {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({
					flowId: this.currentFlowId,
					nodeId,
				}),
			});

			if (!res.ok) {
				const err = await res.json().catch(() => ({}));
				throw new Error((err as any).error || `HTTP ${res.status}`);
			}

			const data = await res.json();
			if (data.node) {
				const idx = this.nodes.findIndex(n => n.id === nodeId);
				if (idx >= 0) {
					this.nodes[idx] = { ...data.node, position: { ...data.node.position }, data: { ...data.node.data } };
					this.exitInlineEdit();
					this.drawCanvasContent();
					this.scheduleSave();
				}
			}
		} catch (e: any) {
			console.error('[CampaignPlanner] AI fill error:', e.message);
			alert('Failed to AI fill: ' + e.message);
		}
	}

	private updateToolbarStaleCount() {
		const btn = this.shadow.getElementById('regen-stale-btn');
		const count = this.getStaleCount();
		if (btn) {
			btn.style.display = count > 0 ? '' : 'none';
			btn.textContent = `\u26a0 Regen ${count} Stale`;
		}
	}

	// ── Port rendering ──

	private renderPortsSvg(node: CampaignPlannerNode): string {
		const s = getNodeSize(node);
		const defs = getPortDefs(node.type);
		return defs.map(p => {
			const cx = node.position.x + s.w * p.xFrac;
			const cy = node.position.y + s.h * p.yFrac;
			const arrow = this.portArrowPath(cx, cy, p);
			return `<g class="port-group" data-port-kind="${p.kind}" data-port-dir="${p.dir}" data-node-id="${node.id}">
				<circle class="port-hit" cx="${cx}" cy="${cy}" r="12" fill="transparent"/>
				<circle class="port-dot" cx="${cx}" cy="${cy}" r="5" style="fill:${p.color};color:${p.color}"/>
				<path class="port-arrow" d="${arrow}" style="fill:${p.color}" opacity="0.7"/>
			</g>`;
		}).join('');
	}

	// ── Edge rendering ──

	private renderAllEdges(): string {
		return this.edges.map(edge => this.renderEdge(edge)).join('');
	}

	private renderEdge(edge: CampaignEdge): string {
		const fromNode = this.nodes.find(n => n.id === edge.from);
		const toNode = this.nodes.find(n => n.id === edge.to);
		if (!fromNode || !toNode) return '';

		// Determine source/target port positions
		let sourcePortKind: string;
		let targetPortKind: string;

		switch (edge.type) {
			case 'publish':
				sourcePortKind = 'publish';
				targetPortKind = 'content-in';
				break;
			case 'sequence':
				sourcePortKind = 'sequence-out';
				targetPortKind = 'sequence-in';
				break;
			case 'target':
				sourcePortKind = 'publish';
				targetPortKind = 'target-in';
				break;
			case 'feeds':
				sourcePortKind = 'feeds-out';
				targetPortKind = 'feeds-in';
				break;
			default:
				sourcePortKind = 'publish';
				targetPortKind = 'content-in';
		}

		const p1 = getPortPosition(fromNode, sourcePortKind);
		const p2 = getPortPosition(toNode, targetPortKind);
		if (!p1 || !p2) return '';

		const color = EDGE_COLORS[edge.type] || '#888';
		const style = EDGE_STYLES[edge.type] || { width: 2, dash: '', animated: false };
		const cssClass = edge.type === 'publish' ? 'edge-path-publish'
			: edge.type === 'sequence' ? 'edge-path-sequence'
			: edge.type === 'feeds' ? 'edge-path-feeds'
			: 'edge-path-target';

		// Build bezier path
		let d: string;
		let midX: number;
		let midY: number;

		if (edge.waypoint) {
			const cx1 = (4 * edge.waypoint.x - p1.x - p2.x) / 3;
			const cy1 = (4 * edge.waypoint.y - p1.y - p2.y) / 3;
			const c1x = p1.x + (cx1 - p1.x) * 0.8;
			const c1y = p1.y + (cy1 - p1.y) * 0.8;
			const c2x = p2.x + (cx1 - p2.x) * 0.8;
			const c2y = p2.y + (cy1 - p2.y) * 0.8;
			d = `M ${p1.x} ${p1.y} C ${c1x} ${c1y}, ${c2x} ${c2y}, ${p2.x} ${p2.y}`;
			midX = edge.waypoint.x;
			midY = edge.waypoint.y;
		} else {
			// Horizontal-biased for publish/target (right → left), vertical for sequence (bottom → top)
			if (edge.type === 'sequence') {
				const cy1 = p1.y + (p2.y - p1.y) * 0.4;
				const cy2 = p1.y + (p2.y - p1.y) * 0.6;
				d = `M ${p1.x} ${p1.y} C ${p1.x} ${cy1}, ${p2.x} ${cy2}, ${p2.x} ${p2.y}`;
			} else {
				const dx = p2.x - p1.x;
				const cx1 = p1.x + dx * 0.5;
				const cx2 = p2.x - dx * 0.5;
				d = `M ${p1.x} ${p1.y} C ${cx1} ${p1.y}, ${cx2} ${p2.y}, ${p2.x} ${p2.y}`;
			}
			midX = (p1.x + p2.x) / 2;
			midY = (p1.y + p2.y) / 2;
		}

		const markerEnd = edge.type === 'sequence' ? ' marker-end="url(#arrow-sequence)"' : '';
		const dashAttr = style.dash ? ` stroke-dasharray="${style.dash}"` : '';
		const selected = this.selectedEdgeKey === edge.id ? ' stroke-opacity="1"' : '';

		return `<g class="edge-group" data-edge-id="${edge.id}">
			<path d="${d}" fill="none" stroke="transparent" stroke-width="14" class="edge-hit-area" style="cursor:pointer"/>
			<path d="${d}" fill="none" stroke="${color}" stroke-width="${style.width * 2.5}" stroke-opacity="0.08" class="edge-glow"/>
			<path d="${d}" fill="none" stroke="${color}" stroke-width="${style.width}"${dashAttr} class="${cssClass}" style="--edge-color:${color}"${markerEnd}${selected}/>
			<circle cx="${midX}" cy="${midY}" r="4" class="edge-drag-handle" data-edge-drag="${edge.id}"/>
		</g>`;
	}

	// ── Event listeners ──

	private attachListeners() {
		const svg = this.shadow.getElementById('cp-svg') as SVGSVGElement | null;
		const canvas = this.shadow.getElementById('cp-canvas');
		if (!svg || !canvas) return;

		// View switcher buttons
		this.shadow.querySelectorAll('.cp-view-btn').forEach(btn => {
			btn.addEventListener('click', () => {
				const view = btn.getAttribute('data-view') as 'canvas' | 'timeline' | 'platform' | 'table';
				if (view) this.switchView(view);
			});
		});

		// Toolbar add buttons
		this.shadow.getElementById('add-post')?.addEventListener('click', () => {
			const rect = svg.getBoundingClientRect();
			const cx = (rect.width / 2 - this.canvasPanX) / this.canvasZoom;
			const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom;
			this.addNode('post', cx, cy);
		});
		this.shadow.getElementById('add-platform')?.addEventListener('click', () => {
			const rect = svg.getBoundingClientRect();
			const cx = (rect.width / 2 - this.canvasPanX) / this.canvasZoom;
			const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom;
			this.addNode('platform', cx, cy);
		});
		this.shadow.getElementById('add-audience')?.addEventListener('click', () => {
			const rect = svg.getBoundingClientRect();
			const cx = (rect.width / 2 - this.canvasPanX) / this.canvasZoom;
			const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom;
			this.addNode('audience', cx, cy);
		});
		this.shadow.getElementById('add-brief')?.addEventListener('click', () => {
			const rect = svg.getBoundingClientRect();
			const cx = (rect.width / 2 - this.canvasPanX) / this.canvasZoom;
			const cy = (rect.height / 2 - this.canvasPanY) / this.canvasZoom;
			this.addNode('brief', cx, cy);
			// Auto-open inline edit so user can start typing
			const lastNode = this.nodes[this.nodes.length - 1];
			if (lastNode) setTimeout(() => this.enterInlineEdit(lastNode.id), 50);
		});

		// Import modal
		this.shadow.getElementById('open-import')?.addEventListener('click', () => this.openImportModal());
		this.shadow.getElementById('import-close')?.addEventListener('click', () => this.closeImportModal());
		this.shadow.getElementById('import-modal')?.addEventListener('click', (e) => {
			if ((e.target as HTMLElement).id === 'import-modal') this.closeImportModal();
		});
		this.shadow.getElementById('import-text')?.addEventListener('input', (e) => {
			this.importText = (e.target as HTMLTextAreaElement).value;
		});
		this.shadow.getElementById('import-platform')?.addEventListener('change', (e) => {
			this.importPlatform = (e.target as HTMLSelectElement).value;
		});
		this.shadow.getElementById('import-submit')?.addEventListener('click', () => this.importFromMarkdown());

		// Preview banner actions
		this.shadow.getElementById('preview-keep')?.addEventListener('click', () => this.confirmPreview());
		this.shadow.getElementById('preview-discard')?.addEventListener('click', () => this.discardPreview());
		this.shadow.getElementById('preview-regen')?.addEventListener('click', () => this.regeneratePreview());

		// Regen stale
		this.shadow.getElementById('regen-stale-btn')?.addEventListener('click', () => {
			this.regenStaleNodes();
		});

		// Postiz panel
		this.shadow.getElementById('toggle-postiz')?.addEventListener('click', () => {
			this.postizOpen = !this.postizOpen;
			const panel = this.shadow.getElementById('postiz-panel');
			const iframe = panel?.querySelector('iframe');
			if (panel) panel.classList.toggle('open', this.postizOpen);
			if (iframe && this.postizOpen) iframe.src = 'https://demo.rsocials.online';
			if (iframe && !this.postizOpen) iframe.src = 'about:blank';
		});
		this.shadow.getElementById('close-postiz')?.addEventListener('click', () => {
			this.postizOpen = false;
			const panel = this.shadow.getElementById('postiz-panel');
			const iframe = panel?.querySelector('iframe');
			if (panel) panel.classList.remove('open');
			if (iframe) iframe.src = 'about:blank';
		});

		// Zoom controls
		this.shadow.getElementById('zoom-in')?.addEventListener('click', () => {
			const rect = svg.getBoundingClientRect();
			this.zoomAt(rect.width / 2, rect.height / 2, 1.25);
		});
		this.shadow.getElementById('zoom-out')?.addEventListener('click', () => {
			const rect = svg.getBoundingClientRect();
			this.zoomAt(rect.width / 2, rect.height / 2, 0.8);
		});
		this.shadow.getElementById('zoom-fit')?.addEventListener('click', () => this.fitView());

		// Wheel zoom
		svg.addEventListener('wheel', (e: WheelEvent) => {
			e.preventDefault();
			const rect = svg.getBoundingClientRect();
			const mx = e.clientX - rect.left;
			const my = e.clientY - rect.top;
			const factor = 1 - e.deltaY * 0.003;
			this.zoomAt(mx, my, factor);
		}, { passive: false });

		// Context menu
		svg.addEventListener('contextmenu', (e: MouseEvent) => {
			e.preventDefault();
			const rect = svg.getBoundingClientRect();
			const canvasX = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom;
			const canvasY = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom;
			this.showContextMenu(e.clientX - rect.left, e.clientY - rect.top, canvasX, canvasY);
		});

		// Pointer down — start interactions
		const DRAG_THRESHOLD = 5;

		svg.addEventListener('pointerdown', (e: PointerEvent) => {
			if (e.button === 2) return; // right-click handled by contextmenu
			if (e.button !== 0) return;
			this.closeContextMenu();

			const target = e.target as Element;

			// Edge drag handle?
			const edgeDragEl = target.closest('[data-edge-drag]');
			if (edgeDragEl) {
				this.draggingEdgeKey = edgeDragEl.getAttribute('data-edge-drag');
				e.preventDefault();
				return;
			}

			// Edge hit area? → select edge
			const edgeGroup = target.closest('.edge-group');
			if (edgeGroup && target.classList.contains('edge-hit-area')) {
				this.selectedEdgeKey = edgeGroup.getAttribute('data-edge-id');
				this.selectedNodeId = null;
				this.exitInlineEdit();
				this.updateSelectionHighlight();
				this.redrawEdges();
				e.preventDefault();
				return;
			}

			// Port click → wiring
			const portGroup = target.closest('.port-group');
			if (portGroup) {
				const nodeId = portGroup.getAttribute('data-node-id');
				const portKind = portGroup.getAttribute('data-port-kind');
				const portDir = portGroup.getAttribute('data-port-dir');

				if (this.wiringActive) {
					// Try to complete wiring
					if (portDir === 'in' && nodeId && nodeId !== this.wiringSourceNodeId) {
						this.completeWiring(nodeId);
					} else {
						this.cancelWiring();
					}
				} else if (portDir === 'out' && nodeId && portKind) {
					this.enterWiring(nodeId, portKind);
				}
				e.preventDefault();
				return;
			}

			// Node drag
			const nodeGroup = target.closest('[data-node-id]');
			if (nodeGroup) {
				// If wiring, check if clicking on a valid target node
				if (this.wiringActive) {
					const nodeId = nodeGroup.getAttribute('data-node-id');
					if (nodeId && nodeId !== this.wiringSourceNodeId) {
						const targetNode = this.nodes.find(n => n.id === nodeId);
						if (targetNode) {
							const targetPort = this.getWiringTargetPort(targetNode);
							if (targetPort) {
								this.completeWiring(nodeId);
								e.preventDefault();
								return;
							}
						}
					}
					this.cancelWiring();
					e.preventDefault();
					return;
				}

				const nodeId = nodeGroup.getAttribute('data-node-id')!;
				const node = this.nodes.find(n => n.id === nodeId);
				if (node) {
					this.draggingNodeId = nodeId;
					this.dragStartX = e.clientX;
					this.dragStartY = e.clientY;
					this.dragNodeStartX = node.position.x;
					this.dragNodeStartY = node.position.y;
					this.nodeDragStarted = false;
				}
				e.preventDefault();
				return;
			}

			// Canvas pan (clicked on background)
			if (this.wiringActive) { this.cancelWiring(); }
			this.isPanning = true;
			this.panStartX = e.clientX;
			this.panStartY = e.clientY;
			this.panStartPanX = this.canvasPanX;
			this.panStartPanY = this.canvasPanY;
			canvas.classList.add('grabbing');
			e.preventDefault();
		});

		// Global pointer move/up
		this._boundPointerMove = (e: PointerEvent) => {
			// Wiring drag
			if (this.wiringActive) {
				this.wiringDragging = true;
				this.wiringPointerX = e.clientX;
				this.wiringPointerY = e.clientY;
				this.updateWiringTempLine();
				return;
			}

			// Edge waypoint drag
			if (this.draggingEdgeKey) {
				const rect = svg.getBoundingClientRect();
				const canvasX = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom;
				const canvasY = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom;
				this.setEdgeWaypoint(this.draggingEdgeKey, canvasX, canvasY);
				this.redrawEdges();
				return;
			}

			// Pan
			if (this.isPanning) {
				this.canvasPanX = this.panStartPanX + (e.clientX - this.panStartX);
				this.canvasPanY = this.panStartPanY + (e.clientY - this.panStartY);
				this.updateCanvasTransform();
				return;
			}

			// Node drag
			if (this.draggingNodeId) {
				const rawDx = e.clientX - this.dragStartX;
				const rawDy = e.clientY - this.dragStartY;
				if (!this.nodeDragStarted) {
					if (Math.abs(rawDx) < DRAG_THRESHOLD && Math.abs(rawDy) < DRAG_THRESHOLD) return;
					this.nodeDragStarted = true;
				}
				const dx = rawDx / this.canvasZoom;
				const dy = rawDy / this.canvasZoom;
				const node = this.nodes.find(n => n.id === this.draggingNodeId);
				if (node) {
					node.position.x = this.dragNodeStartX + dx;
					node.position.y = this.dragNodeStartY + dy;
					this.updateNodePosition(node);
					this.redrawEdges();
				}
			}
		};

		this._boundPointerUp = (e: PointerEvent) => {
			// Edge drag end
			if (this.draggingEdgeKey) {
				this.draggingEdgeKey = null;
				this.scheduleSave();
				return;
			}

			// Pan end
			if (this.isPanning) {
				this.isPanning = false;
				canvas.classList.remove('grabbing');
				return;
			}

			// Node drag/click end
			if (this.draggingNodeId) {
				const clickedNodeId = this.draggingNodeId;
				const wasDragged = this.nodeDragStarted;
				this.draggingNodeId = null;
				this.nodeDragStarted = false;

				if (!wasDragged) {
					// Click → select + open inline editor
					this.selectedNodeId = clickedNodeId;
					this.selectedEdgeKey = null;
					this.updateSelectionHighlight();
					this.redrawEdges();
					this.enterInlineEdit(clickedNodeId);
				} else {
					this.scheduleSave();
				}
			}
		};

		document.addEventListener('pointermove', this._boundPointerMove);
		document.addEventListener('pointerup', this._boundPointerUp);

		// Double-click → thread navigation
		svg.addEventListener('dblclick', (e: MouseEvent) => {
			const target = e.target as Element;
			const nodeGroup = target.closest('[data-node-id]');
			if (!nodeGroup) return;
			const nodeId = nodeGroup.getAttribute('data-node-id')!;
			const node = this.nodes.find(n => n.id === nodeId);
			if (node?.type === 'thread') {
				const d = node.data as ThreadNodeData;
				if (d.threadId) {
					window.location.href = `${this.basePath}thread-editor/${d.threadId}/edit`;
				}
			}
		});

		// Keyboard shortcuts
		this._boundKeyDown = (e: KeyboardEvent) => {
			// Ignore if typing in an input/textarea
			const tag = (e.target as HTMLElement)?.tagName;
			if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;

			// Escape: in alt views → switch back to canvas
			if (e.key === 'Escape') {
				if (this.currentView !== 'canvas') {
					this.switchView('canvas');
					return;
				}
				if (this.wiringActive) this.cancelWiring();
				else if (this.inlineEditNodeId) this.exitInlineEdit();
				else if (this.selectedNodeId) { this.selectedNodeId = null; this.updateSelectionHighlight(); }
				else if (this.selectedEdgeKey) { this.selectedEdgeKey = null; this.redrawEdges(); }
				return;
			}

			// Canvas-only shortcuts
			if (this.currentView !== 'canvas') return;

			if (e.key === 'Delete' || e.key === 'Backspace') {
				if (this.selectedNodeId) {
					this.deleteNode(this.selectedNodeId);
				} else if (this.selectedEdgeKey) {
					this.deleteEdge(this.selectedEdgeKey);
				}
			} else if (e.key === 'f' || e.key === 'F') {
				this.fitView();
			} else if (e.key === '+' || e.key === '=') {
				const rect = svg.getBoundingClientRect();
				this.zoomAt(rect.width / 2, rect.height / 2, 1.15);
			} else if (e.key === '-' || e.key === '_') {
				const rect = svg.getBoundingClientRect();
				this.zoomAt(rect.width / 2, rect.height / 2, 0.87);
			}
		};
		document.addEventListener('keydown', this._boundKeyDown);

		// Touch gestures
		const getTouchCenter = (touches: TouchList) => ({
			x: (touches[0].clientX + touches[1].clientX) / 2,
			y: (touches[0].clientY + touches[1].clientY) / 2,
		});
		const getTouchDist = (touches: TouchList) =>
			Math.hypot(touches[0].clientX - touches[1].clientX, touches[0].clientY - touches[1].clientY);

		svg.addEventListener('touchstart', (e: TouchEvent) => {
			if (e.touches.length === 2) {
				e.preventDefault();
				this.isTouchPanning = true;
				this.isPanning = false;
				if (this.draggingNodeId) { this.draggingNodeId = null; this.nodeDragStarted = false; }
				if (this.wiringActive) this.cancelWiring();
				this.lastTouchCenter = getTouchCenter(e.touches);
				this.lastTouchDist = getTouchDist(e.touches);
			}
		}, { passive: false });

		svg.addEventListener('touchmove', (e: TouchEvent) => {
			if (e.touches.length === 2 && this.isTouchPanning) {
				e.preventDefault();
				const center = getTouchCenter(e.touches);
				const dist = getTouchDist(e.touches);

				if (this.lastTouchCenter) {
					this.canvasPanX += center.x - this.lastTouchCenter.x;
					this.canvasPanY += center.y - this.lastTouchCenter.y;
				}

				if (this.lastTouchDist && this.lastTouchDist > 0) {
					const zoomDelta = dist / this.lastTouchDist;
					const newZoom = Math.max(0.1, Math.min(4, this.canvasZoom * zoomDelta));
					const rect = svg.getBoundingClientRect();
					const cx = center.x - rect.left;
					const cy = center.y - rect.top;
					this.canvasPanX = cx - (cx - this.canvasPanX) * (newZoom / this.canvasZoom);
					this.canvasPanY = cy - (cy - this.canvasPanY) * (newZoom / this.canvasZoom);
					this.canvasZoom = newZoom;
				}

				this.lastTouchCenter = center;
				this.lastTouchDist = dist;
				this.updateCanvasTransform();
			}
		}, { passive: false });

		svg.addEventListener('touchend', (e: TouchEvent) => {
			if (e.touches.length < 2) {
				this.lastTouchCenter = null;
				this.lastTouchDist = null;
				this.isTouchPanning = false;
			}
		});
	}
}

customElements.define('folk-campaign-planner', FolkCampaignPlanner);
