/**
 * <folk-campaign-workflow> — n8n-style campaign workflow builder for rSocials.
 *
 * Renders social-media-specific workflow nodes (triggers, delays, conditions,
 * actions) on an SVG canvas with ports, Bezier wiring, node palette, config
 * panel, and REST persistence.
 *
 * Attributes:
 *   space — space slug (default "demo")
 */

import { CAMPAIGN_NODE_CATALOG, socialsDocId } from '../schemas';
import type {
	CampaignWorkflowNodeDef,
	CampaignWorkflowNodeCategory,
	CampaignWorkflowNode,
	CampaignWorkflowEdge,
	CampaignWorkflow,
	ThreadData,
	SocialsDoc,
} from '../schemas';

// ── Constants ──

const NODE_WIDTH = 220;
const NODE_HEIGHT = 80;
const PORT_RADIUS = 5;

const CATEGORY_COLORS: Record<CampaignWorkflowNodeCategory, string> = {
	trigger: '#3b82f6',
	delay: '#a855f7',
	condition: '#f59e0b',
	action: '#10b981',
};

const PORT_COLORS: Record<string, string> = {
	trigger: '#ef4444',
	data: '#3b82f6',
	boolean: '#f59e0b',
};

// ── Helpers ──

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

function getNodeDef(type: string): CampaignWorkflowNodeDef | undefined {
	return CAMPAIGN_NODE_CATALOG.find(n => n.type === type);
}

function getPortX(node: CampaignWorkflowNode, _portName: string, direction: 'input' | 'output'): number {
	return direction === 'input' ? node.position.x : node.position.x + NODE_WIDTH;
}

function getPortY(node: CampaignWorkflowNode, portName: string, direction: 'input' | 'output'): number {
	const def = getNodeDef(node.type);
	if (!def) return node.position.y + NODE_HEIGHT / 2;
	const ports = direction === 'input' ? def.inputs : def.outputs;
	const idx = ports.findIndex(p => p.name === portName);
	if (idx === -1) return node.position.y + NODE_HEIGHT / 2;
	const spacing = NODE_HEIGHT / (ports.length + 1);
	return node.position.y + spacing * (idx + 1);
}

function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
	const dx = Math.abs(x2 - x1) * 0.5;
	return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}

// ── Component ──

class FolkCampaignWorkflow extends HTMLElement {
	private shadow: ShadowRoot;
	private space = '';

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

	// Data
	private targetWorkflowId = '';
	private workflows: CampaignWorkflow[] = [];
	private currentWorkflowId = '';
	private nodes: CampaignWorkflowNode[] = [];
	private edges: CampaignWorkflowEdge[] = [];
	private workflowName = 'New Campaign Workflow';
	private workflowEnabled = true;

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

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

	// Selection & config
	private selectedNodeId: string | null = null;
	private configOpen = false;

	// Wiring
	private wiringActive = false;
	private wiringSourceNodeId: string | null = null;
	private wiringSourcePortName: string | null = null;
	private wiringSourceDir: 'input' | 'output' | null = null;
	private wiringPointerX = 0;
	private wiringPointerY = 0;

	// Persistence
	private saveTimer: ReturnType<typeof setTimeout> | null = null;
	private saveIndicator = '';

	// Execution log
	private execLog: { nodeId: string; status: string; message: string; durationMs: number }[] = [];

	// Thread cache (for publish-thread node picker)
	private cachedThreads: ThreadData[] = [];
	private threadsLoaded = false;

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

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

	static get observedAttributes() { return ['space', 'workflow']; }

	connectedCallback() {
		this.space = this.getAttribute('space') || 'demo';
		this.targetWorkflowId = this.getAttribute('workflow') || '';
		this.initData();
	}

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

	// ── Data init ──

	private async initData() {
		try {
			const res = await fetch(`${this.basePath}api/campaign-workflows`);
			if (res.ok) {
				const data = await res.json();
				this.workflows = data.results || [];
				if (this.workflows.length > 0) {
					const target = this.targetWorkflowId
						? this.workflows.find(w => w.id === this.targetWorkflowId)
						: undefined;
					this.loadWorkflow(target || this.workflows[0]);
				}
			}
		} catch {
			console.warn('[CampaignWorkflow] Failed to load workflows');
		}
		this.render();
		requestAnimationFrame(() => this.fitView());
	}

	private loadThreads() {
		if (this.threadsLoaded) return;
		this.threadsLoaded = true;
		const runtime = (window as any).__rspaceOfflineRuntime;
		if (!runtime?.isInitialized) return;
		try {
			const docId = socialsDocId(this.space);
			const doc = runtime.documents?.get(docId) as SocialsDoc | undefined;
			if (doc?.threads) {
				this.cachedThreads = Object.values(doc.threads).sort((a, b) => b.updatedAt - a.updatedAt);
			}
		} catch { /* offline runtime not available */ }
	}

	private loadWorkflow(wf: CampaignWorkflow) {
		this.currentWorkflowId = wf.id;
		this.workflowName = wf.name;
		this.workflowEnabled = wf.enabled;
		this.nodes = wf.nodes.map(n => ({ ...n, position: { ...n.position } }));
		this.edges = wf.edges.map(e => ({ ...e }));
		this.selectedNodeId = null;
		this.configOpen = false;
		this.execLog = [];
	}

	// ── Persistence ──

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

	private async executeSave() {
		if (!this.currentWorkflowId) return;
		try {
			await fetch(`${this.basePath}api/campaign-workflows/${this.currentWorkflowId}`, {
				method: 'PUT',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({
					name: this.workflowName,
					enabled: this.workflowEnabled,
					nodes: this.nodes,
					edges: this.edges,
				}),
			});
			this.saveIndicator = 'Saved';
		} catch {
			this.saveIndicator = 'Save failed';
		}
		this.updateSaveIndicator();
		setTimeout(() => { this.saveIndicator = ''; this.updateSaveIndicator(); }, 2000);
	}

	private async createWorkflow() {
		try {
			const res = await fetch(`${this.basePath}api/campaign-workflows`, {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({ name: 'New Campaign Workflow' }),
			});
			if (res.ok) {
				const wf = await res.json();
				this.workflows.push(wf);
				this.loadWorkflow(wf);
				this.render();
				requestAnimationFrame(() => this.fitView());
			}
		} catch {
			console.error('[CampaignWorkflow] Failed to create workflow');
		}
	}

	private async deleteWorkflow() {
		if (!this.currentWorkflowId) return;
		try {
			await fetch(`${this.basePath}api/campaign-workflows/${this.currentWorkflowId}`, { method: 'DELETE' });
			this.workflows = this.workflows.filter(w => w.id !== this.currentWorkflowId);
			if (this.workflows.length > 0) {
				this.loadWorkflow(this.workflows[0]);
			} else {
				this.currentWorkflowId = '';
				this.nodes = [];
				this.edges = [];
				this.workflowName = '';
			}
			this.render();
		} catch {
			console.error('[CampaignWorkflow] Failed to delete workflow');
		}
	}

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

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

	private updateSaveIndicator() {
		const el = this.shadow.getElementById('save-indicator');
		if (el) el.textContent = this.saveIndicator;
	}

	private fitView() {
		const svg = this.shadow.getElementById('cw-svg') as SVGSVGElement | null;
		if (!svg || this.nodes.length === 0) return;
		const rect = svg.getBoundingClientRect();
		if (rect.width === 0 || rect.height === 0) return;

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

		const pad = 60;
		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.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();
	}

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

	// ── Rendering ──

	private render() {
		const paletteGroups: CampaignWorkflowNodeCategory[] = ['trigger', 'delay', 'condition', 'action'];

		this.shadow.innerHTML = `
			<link rel="stylesheet" href="/modules/rsocials/campaign-workflow.css">
			<div class="cw-root">
				<div class="cw-toolbar">
					<div class="cw-toolbar__title">
						<a id="btn-back" href="${this.basePath}campaigns" class="cw-btn cw-btn--back">\u2190 Dashboard</a>
						<select class="cw-workflow-select" id="workflow-select">
							${this.workflows.map(w => `<option value="${w.id}" ${w.id === this.currentWorkflowId ? 'selected' : ''}>${esc(w.name)}</option>`).join('')}
						</select>
						<input id="wf-name" type="text" value="${esc(this.workflowName)}" placeholder="Workflow name">
					</div>
					<div class="cw-toolbar__actions">
						<div class="cw-toggle">
							<input type="checkbox" id="wf-enabled" ${this.workflowEnabled ? 'checked' : ''}>
							<label for="wf-enabled">Enabled</label>
						</div>
						<span class="cw-save-indicator" id="save-indicator">${this.saveIndicator}</span>
						<button class="cw-btn cw-btn--run" id="btn-run">&#9654; Run All</button>
						<button class="cw-btn" id="btn-new">+ New</button>
						<button class="cw-btn" id="btn-delete">Delete</button>
					</div>
				</div>

				<div class="cw-canvas-area">
					<div class="cw-palette" id="palette">
						${paletteGroups.map(cat => `
							<div>
								<div class="cw-palette__group-title">${cat}s</div>
								${CAMPAIGN_NODE_CATALOG.filter(n => n.category === cat).map(n => `
									<div class="cw-palette__card" data-node-type="${n.type}" draggable="true">
										<span class="cw-palette__card-icon">${n.icon}</span>
										<span class="cw-palette__card-label">${esc(n.label)}</span>
									</div>
								`).join('')}
							</div>
						`).join('')}
					</div>

					<div class="cw-canvas" id="cw-canvas">
						<svg id="cw-svg" xmlns="http://www.w3.org/2000/svg">
							<g id="canvas-transform">
								<g id="edge-layer">${this.renderAllEdges()}</g>
								<g id="wire-layer"></g>
								<g id="node-layer">${this.renderAllNodes()}</g>
							</g>
						</svg>
						<div class="cw-zoom-controls">
							<button class="cw-zoom-btn" id="zoom-out" title="Zoom out">
								<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
							</button>
							<span class="cw-zoom-sep"></span>
							<span class="cw-zoom-level" id="zoom-level">${Math.round(this.canvasZoom * 100)}%</span>
							<span class="cw-zoom-sep"></span>
							<button class="cw-zoom-btn" id="zoom-in" title="Zoom in">
								<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M8 3v10M3 8h10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
							</button>
							<span class="cw-zoom-sep"></span>
							<button class="cw-zoom-btn" id="zoom-fit" title="Fit to view">
								<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M2 6V3a1 1 0 011-1h3M10 2h3a1 1 0 011 1v3M14 10v3a1 1 0 01-1 1h-3M6 14H3a1 1 0 01-1-1v-3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
							</button>
						</div>
					</div>

					<div class="cw-config ${this.configOpen ? 'open' : ''}" id="config-panel">
						${this.renderConfigPanel()}
					</div>
				</div>
			</div>
		`;

		this.attachEventListeners();
	}

	private renderAllNodes(): string {
		return this.nodes.map(node => this.renderNode(node)).join('');
	}

	private renderNode(node: CampaignWorkflowNode): string {
		const def = getNodeDef(node.type);
		if (!def) return '';
		const catColor = CATEGORY_COLORS[def.category];
		const status = node.runtimeStatus || 'idle';
		const isSelected = node.id === this.selectedNodeId;

		// Ports
		let portsHtml = '';
		for (const inp of def.inputs) {
			const y = getPortY(node, inp.name, 'input');
			const x = node.position.x;
			const color = PORT_COLORS[inp.type] || '#6b7280';
			portsHtml += `
				<g class="cw-port-group" data-node-id="${node.id}" data-port-name="${inp.name}" data-port-dir="input">
					<circle class="cw-port-dot" cx="${x}" cy="${y}" r="${PORT_RADIUS}" fill="${color}" stroke="#0f0f23" stroke-width="2"/>
					<circle cx="${x}" cy="${y}" r="12" fill="transparent" class="cw-port-hit"/>
				</g>`;
		}
		for (const out of def.outputs) {
			const y = getPortY(node, out.name, 'output');
			const x = node.position.x + NODE_WIDTH;
			const color = PORT_COLORS[out.type] || '#6b7280';
			portsHtml += `
				<g class="cw-port-group" data-node-id="${node.id}" data-port-name="${out.name}" data-port-dir="output">
					<circle class="cw-port-dot" cx="${x}" cy="${y}" r="${PORT_RADIUS}" fill="${color}" stroke="#0f0f23" stroke-width="2"/>
					<circle cx="${x}" cy="${y}" r="12" fill="transparent" class="cw-port-hit"/>
				</g>`;
		}

		return `
			<g class="cw-node cw-node--${def.category} ${isSelected ? 'selected' : ''}" data-node-id="${node.id}">
				<foreignObject x="${node.position.x}" y="${node.position.y}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}">
					<div xmlns="http://www.w3.org/1999/xhtml" style="width:100%;height:100%">
						<div class="cw-node-header" style="border-left: 3px solid ${catColor}">
							<span class="cw-node-icon">${def.icon}</span>
							<span class="cw-node-label">${esc(node.label)}</span>
							<span class="cw-node-cat cw-node-cat--${def.category}">${def.category}</span>
							<span class="cw-node-status ${status}"></span>
						</div>
						<div class="cw-node-ports">
							<div class="cw-node-inputs">
								${def.inputs.map(p => `<span class="cw-port-label">${p.name}</span>`).join('')}
							</div>
							<div class="cw-node-outputs">
								${def.outputs.map(p => `<span class="cw-port-label">${p.name}</span>`).join('')}
							</div>
						</div>
					</div>
				</foreignObject>
				${portsHtml}
			</g>`;
	}

	private renderAllEdges(): string {
		return this.edges.map(edge => {
			const fromNode = this.nodes.find(n => n.id === edge.fromNode);
			const toNode = this.nodes.find(n => n.id === edge.toNode);
			if (!fromNode || !toNode) return '';

			const x1 = getPortX(fromNode, edge.fromPort, 'output');
			const y1 = getPortY(fromNode, edge.fromPort, 'output');
			const x2 = getPortX(toNode, edge.toPort, 'input');
			const y2 = getPortY(toNode, edge.toPort, 'input');

			const fromDef = getNodeDef(fromNode.type);
			const outPort = fromDef?.outputs.find(p => p.name === edge.fromPort);
			const color = outPort ? (PORT_COLORS[outPort.type] || '#6b7280') : '#6b7280';
			const d = bezierPath(x1, y1, x2, y2);

			return `
				<g class="cw-edge-group" data-edge-id="${edge.id}">
					<path class="cw-edge-hit" d="${d}"/>
					<path class="cw-edge-path" d="${d}" stroke="${color}" stroke-opacity="0.6"/>
				</g>`;
		}).join('');
	}

	private renderConfigPanel(): string {
		if (!this.selectedNodeId) {
			return `
				<div class="cw-config__header">
					<span>No node selected</span>
					<button class="cw-config__header-close" id="config-close">&times;</button>
				</div>
				<div class="cw-config__body">
					<p style="color:#64748b;font-size:12px">Click a node to configure it.</p>
				</div>`;
		}

		const node = this.nodes.find(n => n.id === this.selectedNodeId);
		if (!node) return '';
		const def = getNodeDef(node.type);
		if (!def) return '';

		// Lazy-load threads when configuring a publish-thread node
		if (node.type === 'publish-thread') this.loadThreads();

		const fieldsHtml = def.configSchema.map(field => {
			const val = node.config[field.key] ?? '';

			// Replace threadId text input with a select dropdown for publish-thread nodes
			if (field.key === 'threadId' && node.type === 'publish-thread') {
				const threads = this.cachedThreads;
				const threadOptions = threads.length === 0
					? '<option value="">No threads yet</option>'
					: [`<option value="">— Select a thread —</option>`,
						...threads.map(t =>
							`<option value="${t.id}" ${val === t.id ? 'selected' : ''}>${esc(t.title || 'Untitled')} (${t.tweets.length} tweets)</option>`
						)].join('');
				return `
					<div class="cw-config__field">
						<label>${esc(field.label)}</label>
						<select data-config-key="threadId" id="config-thread-picker">${threadOptions}</select>
					</div>`;
			}

			if (field.type === 'select') {
				const options = (field.options || []).map(o =>
					`<option value="${o}" ${val === o ? 'selected' : ''}>${o}</option>`
				).join('');
				return `
					<div class="cw-config__field">
						<label>${esc(field.label)}</label>
						<select data-config-key="${field.key}">${options}</select>
					</div>`;
			}
			if (field.type === 'textarea') {
				return `
					<div class="cw-config__field">
						<label>${esc(field.label)}</label>
						<textarea data-config-key="${field.key}" placeholder="${esc(field.placeholder || '')}">${esc(String(val))}</textarea>
					</div>`;
			}
			return `
				<div class="cw-config__field">
					<label>${esc(field.label)}</label>
					<input type="${field.type === 'number' ? 'number' : 'text'}" data-config-key="${field.key}" value="${esc(String(val))}" placeholder="${esc(field.placeholder || '')}">
				</div>`;
		}).join('');

		const logHtml = this.execLog.filter(e => e.nodeId === this.selectedNodeId).map(e => `
			<div class="cw-exec-log__entry">
				<span class="cw-exec-log__dot ${e.status}"></span>
				<span>${esc(e.message)} (${e.durationMs}ms)</span>
			</div>
		`).join('');

		const threadBtnHtml = node.type === 'publish-thread'
			? `<button class="cw-config__thread-btn" id="config-open-thread">
				<span style="margin-right:4px">🧵</span>
				${node.config.threadId ? 'Edit Thread' : 'Create Thread'}
			</button>`
			: '';

		return `
			<div class="cw-config__header">
				<span>${def.icon} ${esc(node.label)}</span>
				<button class="cw-config__header-close" id="config-close">&times;</button>
			</div>
			<div class="cw-config__body">
				<div class="cw-config__field">
					<label>Label</label>
					<input type="text" id="config-label" value="${esc(node.label)}">
				</div>
				${fieldsHtml}
				${threadBtnHtml}
				${logHtml ? `<div class="cw-exec-log"><div class="cw-exec-log__title">Execution Log</div>${logHtml}</div>` : ''}
				<button class="cw-config__delete" id="config-delete-node">Delete Node</button>
			</div>`;
	}

	// ── Redraw helpers ──

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

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

	private updateNodePosition(node: CampaignWorkflowNode) {
		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));
		}
		const def = getNodeDef(node.type);
		if (!def) return;
		const portGroups = g.querySelectorAll('.cw-port-group');
		portGroups.forEach(pg => {
			const portName = (pg as HTMLElement).dataset.portName!;
			const dir = (pg as HTMLElement).dataset.portDir as 'input' | 'output';
			const x = dir === 'input' ? node.position.x : node.position.x + NODE_WIDTH;
			const ports = dir === 'input' ? def.inputs : def.outputs;
			const idx = ports.findIndex(p => p.name === portName);
			const spacing = NODE_HEIGHT / (ports.length + 1);
			const y = node.position.y + spacing * (idx + 1);
			pg.querySelectorAll('circle').forEach(c => {
				c.setAttribute('cx', String(x));
				c.setAttribute('cy', String(y));
			});
		});
	}

	private refreshConfigPanel() {
		const panel = this.shadow.getElementById('config-panel');
		if (!panel) return;
		panel.className = `cw-config ${this.configOpen ? 'open' : ''}`;
		panel.innerHTML = this.renderConfigPanel();
		this.attachConfigListeners();
	}

	// ── Node operations ──

	private addNode(type: string, x: number, y: number) {
		const def = getNodeDef(type);
		if (!def) return;
		const id = `cn-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
		const node: CampaignWorkflowNode = {
			id,
			type: def.type,
			label: def.label,
			position: { x, y },
			config: {},
		};
		this.nodes.push(node);
		this.drawCanvasContent();
		this.selectNode(id);
		this.scheduleSave();
	}

	private deleteNode(nodeId: string) {
		this.nodes = this.nodes.filter(n => n.id !== nodeId);
		this.edges = this.edges.filter(e => e.fromNode !== nodeId && e.toNode !== nodeId);
		if (this.selectedNodeId === nodeId) {
			this.selectedNodeId = null;
			this.configOpen = false;
		}
		this.drawCanvasContent();
		this.refreshConfigPanel();
		this.scheduleSave();
	}

	private selectNode(nodeId: string) {
		this.selectedNodeId = nodeId;
		this.configOpen = true;
		const nodeLayer = this.shadow.getElementById('node-layer');
		if (nodeLayer) {
			nodeLayer.querySelectorAll('.cw-node').forEach(g => {
				g.classList.toggle('selected', g.getAttribute('data-node-id') === nodeId);
			});
		}
		this.refreshConfigPanel();
	}

	// ── Wiring ──

	private enterWiring(nodeId: string, portName: string, dir: 'input' | 'output') {
		if (dir !== 'output') return;
		this.wiringActive = true;
		this.wiringSourceNodeId = nodeId;
		this.wiringSourcePortName = portName;
		this.wiringSourceDir = dir;
		const canvas = this.shadow.getElementById('cw-canvas');
		if (canvas) canvas.classList.add('wiring');
	}

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

	private completeWiring(targetNodeId: string, targetPortName: string, targetDir: 'input' | 'output') {
		if (!this.wiringSourceNodeId || !this.wiringSourcePortName) { this.cancelWiring(); return; }
		if (targetDir !== 'input') { this.cancelWiring(); return; }
		if (targetNodeId === this.wiringSourceNodeId) { this.cancelWiring(); return; }

		const exists = this.edges.some(e =>
			e.fromNode === this.wiringSourceNodeId && e.fromPort === this.wiringSourcePortName &&
			e.toNode === targetNodeId && e.toPort === targetPortName
		);
		if (exists) { this.cancelWiring(); return; }

		const edgeId = `ce-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
		this.edges.push({
			id: edgeId,
			fromNode: this.wiringSourceNodeId,
			fromPort: this.wiringSourcePortName,
			toNode: targetNodeId,
			toPort: targetPortName,
		});

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

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

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

		const x1 = getPortX(sourceNode, this.wiringSourcePortName!, 'output');
		const y1 = getPortY(sourceNode, this.wiringSourcePortName!, 'output');

		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 d = bezierPath(x1, y1, x2, y2);
		wireLayer.innerHTML = `<path class="cw-wiring-temp" d="${d}"/>`;
	}

	// ── Execution ──

	private async runWorkflow() {
		if (!this.currentWorkflowId) return;
		for (const n of this.nodes) {
			n.runtimeStatus = 'running';
		}
		this.drawCanvasContent();

		try {
			const res = await fetch(`${this.basePath}api/campaign-workflows/${this.currentWorkflowId}/run`, { method: 'POST' });
			const data = await res.json();
			this.execLog = data.results || [];

			for (const n of this.nodes) {
				const logEntry = this.execLog.find(e => e.nodeId === n.id);
				n.runtimeStatus = logEntry ? (logEntry.status as 'success' | 'error') : 'idle';
				n.runtimeMessage = logEntry?.message;
			}
		} catch {
			for (const n of this.nodes) {
				n.runtimeStatus = 'error';
			}
		}

		this.drawCanvasContent();
		if (this.selectedNodeId) this.refreshConfigPanel();

		setTimeout(() => {
			for (const n of this.nodes) {
				n.runtimeStatus = 'idle';
				n.runtimeMessage = undefined;
			}
			this.drawCanvasContent();
		}, 5000);
	}

	// ── Event listeners ──

	private attachEventListeners() {
		const canvas = this.shadow.getElementById('cw-canvas')!;
		const svg = this.shadow.getElementById('cw-svg')!;
		const palette = this.shadow.getElementById('palette')!;

		// Toolbar
		this.shadow.getElementById('wf-name')?.addEventListener('input', (e) => {
			this.workflowName = (e.target as HTMLInputElement).value;
			this.scheduleSave();
		});

		this.shadow.getElementById('wf-enabled')?.addEventListener('change', (e) => {
			this.workflowEnabled = (e.target as HTMLInputElement).checked;
			this.scheduleSave();
		});

		this.shadow.getElementById('btn-run')?.addEventListener('click', () => this.runWorkflow());
		this.shadow.getElementById('btn-new')?.addEventListener('click', () => this.createWorkflow());
		this.shadow.getElementById('btn-delete')?.addEventListener('click', () => this.deleteWorkflow());

		this.shadow.getElementById('workflow-select')?.addEventListener('change', (e) => {
			const id = (e.target as HTMLSelectElement).value;
			const wf = this.workflows.find(w => w.id === id);
			if (wf) {
				this.loadWorkflow(wf);
				this.drawCanvasContent();
				this.refreshConfigPanel();
				requestAnimationFrame(() => this.fitView());
			}
		});

		// Zoom controls
		this.shadow.getElementById('zoom-in')?.addEventListener('click', () => {
			const rect = svg.getBoundingClientRect();
			this.zoomAt(rect.width / 2, rect.height / 2, 1.2);
		});
		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());

		// Canvas wheel: two-finger trackpad = pan, Ctrl+wheel / pinch = zoom
		canvas.addEventListener('wheel', (e: WheelEvent) => {
			e.preventDefault();
			if (e.ctrlKey || e.metaKey) {
				// Pinch-to-zoom or Ctrl+scroll
				const rect = svg.getBoundingClientRect();
				const factor = e.deltaY < 0 ? 1.1 : 0.9;
				this.zoomAt(e.clientX - rect.left, e.clientY - rect.top, factor);
			} else {
				// Two-finger swipe = pan
				this.canvasPanX -= e.deltaX;
				this.canvasPanY -= e.deltaY;
				this.updateCanvasTransform();
			}
		}, { passive: false });

		// Palette drag
		palette.querySelectorAll('.cw-palette__card').forEach(card => {
			card.addEventListener('dragstart', (e: Event) => {
				const de = e as DragEvent;
				const type = (card as HTMLElement).dataset.nodeType!;
				de.dataTransfer?.setData('text/plain', type);
			});
		});

		// Canvas drop
		canvas.addEventListener('dragover', (e: DragEvent) => { e.preventDefault(); });
		canvas.addEventListener('drop', (e: DragEvent) => {
			e.preventDefault();
			const type = e.dataTransfer?.getData('text/plain');
			if (!type) return;
			const rect = svg.getBoundingClientRect();
			const x = (e.clientX - rect.left - this.canvasPanX) / this.canvasZoom;
			const y = (e.clientY - rect.top - this.canvasPanY) / this.canvasZoom;
			this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2);
		});

		// SVG pointer events
		svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e));

		// Global move/up
		this._boundPointerMove = (e: PointerEvent) => this.handlePointerMove(e);
		this._boundPointerUp = (e: PointerEvent) => this.handlePointerUp(e);
		this._boundKeyDown = (e: KeyboardEvent) => this.handleKeyDown(e);
		document.addEventListener('pointermove', this._boundPointerMove);
		document.addEventListener('pointerup', this._boundPointerUp);
		document.addEventListener('keydown', this._boundKeyDown);

		// Config panel
		this.attachConfigListeners();
	}

	private attachConfigListeners() {
		this.shadow.getElementById('config-close')?.addEventListener('click', () => {
			this.configOpen = false;
			this.selectedNodeId = null;
			const panel = this.shadow.getElementById('config-panel');
			if (panel) panel.className = 'cw-config';
			this.drawCanvasContent();
		});

		this.shadow.getElementById('config-label')?.addEventListener('input', (e) => {
			const node = this.nodes.find(n => n.id === this.selectedNodeId);
			if (node) {
				node.label = (e.target as HTMLInputElement).value;
				this.drawCanvasContent();
				this.scheduleSave();
			}
		});

		this.shadow.getElementById('config-delete-node')?.addEventListener('click', () => {
			if (this.selectedNodeId) this.deleteNode(this.selectedNodeId);
		});

		this.shadow.getElementById('config-thread-picker')?.addEventListener('change', (e) => {
			const node = this.nodes.find(n => n.id === this.selectedNodeId);
			if (!node) return;
			const threadId = (e.target as HTMLSelectElement).value;
			node.config.threadId = threadId;
			if (threadId) {
				const thread = this.cachedThreads.find(t => t.id === threadId);
				if (thread) {
					node.config.threadContent = thread.tweets.join('\n---\n');
					node.label = thread.title || 'Publish Thread';
					// Update the threadContent textarea if visible
					const ta = this.shadow.querySelector('[data-config-key="threadContent"]') as HTMLTextAreaElement | null;
					if (ta) ta.value = node.config.threadContent as string;
					// Update label input
					const li = this.shadow.getElementById('config-label') as HTMLInputElement | null;
					if (li) li.value = node.label;
					this.drawCanvasContent();
				}
			}
			this.scheduleSave();
		});

		this.shadow.getElementById('config-open-thread')?.addEventListener('click', () => {
			const node = this.nodes.find(n => n.id === this.selectedNodeId);
			if (!node) return;
			const threadId = node.config.threadId;
			const url = threadId
				? `${this.basePath}thread-editor/${threadId}/edit`
				: `${this.basePath}thread-editor`;
			window.open(url, '_blank');
		});

		const configPanel = this.shadow.getElementById('config-panel');
		if (configPanel) {
			configPanel.querySelectorAll('[data-config-key]').forEach(el => {
				el.addEventListener('input', (e) => {
					const key = (el as HTMLElement).dataset.configKey!;
					const node = this.nodes.find(n => n.id === this.selectedNodeId);
					if (node) {
						node.config[key] = (e.target as HTMLInputElement).value;
						this.scheduleSave();
					}
				});
				el.addEventListener('change', (e) => {
					const key = (el as HTMLElement).dataset.configKey!;
					const node = this.nodes.find(n => n.id === this.selectedNodeId);
					if (node) {
						node.config[key] = (e.target as HTMLSelectElement).value;
						this.scheduleSave();
					}
				});
			});
		}
	}

	private handlePointerDown(e: PointerEvent) {
		const target = e.target as Element;

		// Port click — start/complete wiring
		const portGroup = target.closest('.cw-port-group') as SVGElement | null;
		if (portGroup) {
			e.stopPropagation();
			const nodeId = portGroup.dataset.nodeId!;
			const portName = portGroup.dataset.portName!;
			const dir = portGroup.dataset.portDir as 'input' | 'output';

			if (this.wiringActive) {
				this.completeWiring(nodeId, portName, dir);
			} else {
				this.enterWiring(nodeId, portName, dir);
			}
			return;
		}

		// Edge click — delete
		const edgeGroup = target.closest('.cw-edge-group') as SVGElement | null;
		if (edgeGroup) {
			e.stopPropagation();
			const edgeId = edgeGroup.dataset.edgeId!;
			this.edges = this.edges.filter(ed => ed.id !== edgeId);
			this.redrawEdges();
			this.scheduleSave();
			return;
		}

		// Node click — select + start drag
		const nodeGroup = target.closest('.cw-node') as SVGElement | null;
		if (nodeGroup) {
			e.stopPropagation();
			if (this.wiringActive) {
				this.cancelWiring();
				return;
			}
			const nodeId = nodeGroup.dataset.nodeId!;
			this.selectNode(nodeId);

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

		// Canvas click — pan or deselect
		if (this.wiringActive) {
			this.cancelWiring();
			return;
		}

		this.isPanning = true;
		this.panStartX = e.clientX;
		this.panStartY = e.clientY;
		this.panStartPanX = this.canvasPanX;
		this.panStartPanY = this.canvasPanY;
		const canvas = this.shadow.getElementById('cw-canvas');
		if (canvas) canvas.classList.add('grabbing');

		if (this.selectedNodeId) {
			this.selectedNodeId = null;
			this.configOpen = false;
			this.drawCanvasContent();
			this.refreshConfigPanel();
		}
	}

	private handlePointerMove(e: PointerEvent) {
		if (this.wiringActive) {
			this.wiringPointerX = e.clientX;
			this.wiringPointerY = e.clientY;
			this.updateWiringTempLine();
			return;
		}

		if (this.draggingNodeId) {
			const node = this.nodes.find(n => n.id === this.draggingNodeId);
			if (node) {
				const dx = (e.clientX - this.dragStartX) / this.canvasZoom;
				const dy = (e.clientY - this.dragStartY) / this.canvasZoom;
				node.position.x = this.dragNodeStartX + dx;
				node.position.y = this.dragNodeStartY + dy;
				this.updateNodePosition(node);
				this.redrawEdges();
			}
			return;
		}

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

	private handlePointerUp(_e: PointerEvent) {
		if (this.draggingNodeId) {
			this.draggingNodeId = null;
			this.scheduleSave();
		}
		if (this.isPanning) {
			this.isPanning = false;
			const canvas = this.shadow.getElementById('cw-canvas');
			if (canvas) canvas.classList.remove('grabbing');
		}
	}

	private handleKeyDown(e: KeyboardEvent) {
		const tag = (e.target as Element)?.tagName;
		const isEditing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';

		if (e.key === 'Escape') {
			if (this.wiringActive) this.cancelWiring();
		}
		if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedNodeId) {
			if (isEditing) return;
			this.deleteNode(this.selectedNodeId);
		}
		if (isEditing) return;
		if (e.key === 'f' || e.key === 'F') {
			this.fitView();
		}
		if (e.key === '=' || e.key === '+') {
			const svg = this.shadow.getElementById('cw-svg');
			if (svg) { const r = svg.getBoundingClientRect(); this.zoomAt(r.width / 2, r.height / 2, 1.2); }
		}
		if (e.key === '-') {
			const svg = this.shadow.getElementById('cw-svg');
			if (svg) { const r = svg.getBoundingClientRect(); this.zoomAt(r.width / 2, r.height / 2, 0.8); }
		}
	}
}

customElements.define('folk-campaign-workflow', FolkCampaignWorkflow);
