/**
 * Socials module — federated social feed aggregator.
 *
 * Slim mod.ts: Automerge doc management, image API routes,
 * page routes (injecting web components), seed template, module export.
 *
 * All UI moved to web components in components/.
 * Thread/campaign CRUD handled by Automerge (no REST CRUD).
 * File-based threads migrated to Automerge on first access.
 */

import { resolve } from "node:path";
import { readdir, readFile } from "node:fs/promises";
import { Hono } from "hono";
import * as Automerge from "@automerge/automerge";
import { renderShell, renderExternalAppShell, escapeHtml, RICH_LANDING_CSS } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import type { SyncServer } from "../../server/local-first/sync-server";
import { renderLanding } from "./landing";
import { MYCOFI_CAMPAIGN, buildDemoCampaignFlow, buildDemoCampaignWorkflow } from "./campaign-data";
import { socialsSchema, socialsDocId, type SocialsDoc, type ThreadData, type Campaign, type CampaignWorkflow, type CampaignWorkflowNode, type CampaignWorkflowEdge, type PendingApproval, type CampaignWizard, type NewsletterDraft, type CampaignPlannerNode, type CampaignEdge, type CampaignFlow, type GoalNodeData, type MessageNodeData, type ToneNodeData, type PostNodeData, type PhaseNodeData, type PlatformNodeData } from "./schemas";
import {
	generateImageFromPrompt,
	downloadAndSaveImage,
	validateImageFile,
	safeExtension,
	saveUploadedFile,
	deleteImageFile,
	deleteOldImage,
} from "./lib/image-gen";
import { DEMO_FEED } from "./lib/types";
import { getListmonkConfig, listmonkFetch } from "./lib/listmonk-proxy";
import { getPostizConfig, getIntegrations, createPost, createThread } from "./lib/postiz-client";
import { verifyToken, extractToken } from "../../server/auth";
import type { EncryptIDClaims } from "../../server/auth";
import { resolveCallerRole, roleAtLeast } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces";

let _syncServer: SyncServer | null = null;

const routes = new Hono();

// ── Automerge doc management ──

function ensureDoc(space: string): SocialsDoc {
	const docId = socialsDocId(space);
	let doc = _syncServer!.getDoc<SocialsDoc>(docId);
	if (!doc) {
		doc = Automerge.change(Automerge.init<SocialsDoc>(), "init", (d) => {
			const init = socialsSchema.init();
			d.meta = init.meta;
			d.meta.spaceSlug = space;
			d.threads = {};
			d.campaigns = {};
			d.campaignFlows = {};
			d.activeFlowId = '';
			d.campaignWorkflows = {};
			d.pendingApprovals = {};
			d.campaignWizards = {};
			d.newsletterDrafts = {};
		});
		_syncServer!.setDoc(docId, doc);
	}
	// Migrate existing docs missing newsletterDrafts
	if (!doc.newsletterDrafts) {
		const docId = socialsDocId(space);
		_syncServer!.changeDoc<SocialsDoc>(docId, "add newsletterDrafts", (d) => {
			d.newsletterDrafts = {} as any;
		});
		doc = _syncServer!.getDoc<SocialsDoc>(docId)!;
	}
	return doc;
}

function getThreadFromDoc(space: string, id: string): ThreadData | undefined {
	const doc = ensureDoc(space);
	return doc.threads?.[id];
}

// ── Migration: file-based threads → Automerge ──

async function migrateFileThreadsToAutomerge(space: string): Promise<void> {
	const doc = ensureDoc(space);
	if (Object.keys(doc.threads || {}).length > 0) return; // Already has threads

	const threadsDir = resolve(process.env.FILES_DIR || "./data/files", "threads");
	let files: string[];
	try {
		files = await readdir(threadsDir);
	} catch {
		return; // No threads directory
	}

	let count = 0;
	const docId = socialsDocId(space);
	for (const f of files) {
		if (!f.endsWith(".json")) continue;
		try {
			const raw = await readFile(resolve(threadsDir, f), "utf-8");
			const thread: ThreadData = JSON.parse(raw);
			_syncServer!.changeDoc<SocialsDoc>(docId, `migrate thread ${thread.id}`, (d) => {
				if (!d.threads) d.threads = {} as any;
				d.threads[thread.id] = thread;
			});
			count++;
		} catch { /* skip corrupt files */ }
	}

	if (count > 0) {
		console.log(`[rSocials] Migrated ${count} file-based threads to Automerge for space "${space}"`);
	}
}

// ── Seed template ──

function seedTemplateSocials(space: string): void {
	if (!_syncServer) return;
	const doc = ensureDoc(space);

	// Seed MYCOFI_CAMPAIGN if no campaigns exist
	if (Object.keys(doc.campaigns || {}).length === 0) {
		const docId = socialsDocId(space);
		const now = Date.now();
		_syncServer.changeDoc<SocialsDoc>(docId, "seed campaign", (d) => {
			if (!d.campaigns) d.campaigns = {} as any;
			d.campaigns[MYCOFI_CAMPAIGN.id] = {
				...MYCOFI_CAMPAIGN,
				createdAt: now,
				updatedAt: now,
			};
		});
	}

	// Seed campaign flow if empty
	if (Object.keys(doc.campaignFlows || {}).length === 0) {
		const docId = socialsDocId(space);
		const flow = buildDemoCampaignFlow();
		_syncServer.changeDoc<SocialsDoc>(docId, "seed campaign flow", (d) => {
			if (!d.campaignFlows) d.campaignFlows = {} as any;
			d.campaignFlows[flow.id] = flow;
			d.activeFlowId = flow.id;
		});
	}

	// Seed campaign workflow if empty
	if (Object.keys(doc.campaignWorkflows || {}).length === 0) {
		const docId = socialsDocId(space);
		const wf = buildDemoCampaignWorkflow();
		_syncServer.changeDoc<SocialsDoc>(docId, "seed campaign workflow", (d) => {
			if (!d.campaignWorkflows) d.campaignWorkflows = {} as any;
			d.campaignWorkflows[wf.id] = wf;
		});
	}

	// Seed a sample thread if empty
	if (Object.keys(doc.threads || {}).length === 0) {
		const docId = socialsDocId(space);
		const now = Date.now();
		const threadId = `t-${now}-seed`;
		_syncServer.changeDoc<SocialsDoc>(docId, "seed thread", (d) => {
			if (!d.threads) d.threads = {} as any;
			d.threads[threadId] = {
				id: threadId,
				name: "rSocials",
				handle: "@rsocials",
				title: "Welcome to Thread Editor",
				tweets: [
					"Welcome to the rSocials Thread Editor! Write your thread content here, separated by --- between tweets.",
					"Each section becomes a separate tweet card with live character counts and thread numbering.",
					"When you're ready, export to Twitter, Bluesky, Mastodon, or LinkedIn. All locally stored, no third-party data mining.",
				],
				createdAt: now,
				updatedAt: now,
			};
		});
		console.log(`[rSocials] Template seeded for "${space}": campaign + sample thread`);
	}
}

// ── API: Health & Info ──

routes.get("/api/health", (c) => c.json({ ok: true, module: "rsocials" }));

routes.get("/api/info", (c) =>
	c.json({
		module: "rsocials",
		description: "Federated social feed aggregator for communities",
		features: ["ActivityPub integration", "RSS feed aggregation", "Link sharing", "Community timeline"],
	}),
);

// ── API: Demo feed ──

routes.get("/api/feed", (c) =>
	c.json({
		items: [
			{ id: "demo-1", type: "post", author: "Alice", content: "Just published our community governance proposal!", source: "fediverse", timestamp: new Date(Date.now() - 3600_000).toISOString(), likes: 12, replies: 3 },
			{ id: "demo-2", type: "link", author: "Bob", content: "Great article on local-first collaboration", url: "https://example.com/local-first", source: "shared", timestamp: new Date(Date.now() - 7200_000).toISOString(), likes: 8, replies: 1 },
			{ id: "demo-3", type: "post", author: "Carol", content: "Welcome new members! Check out rSpace's tools in the app switcher above.", source: "local", timestamp: new Date(Date.now() - 14400_000).toISOString(), likes: 24, replies: 7 },
		],
		demo: true,
	}),
);

// ── API: Threads (read-only, for cross-rApp consumption) ──

routes.get("/api/threads", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const doc = ensureDoc(dataSpace);
	const threads = Object.values(doc.threads || {}).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
	return c.json({ threads, count: threads.length });
});

routes.get("/api/threads/:id", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	const thread = getThreadFromDoc(dataSpace, id);
	if (!thread) return c.json({ error: "Thread not found" }, 404);
	return c.json(thread);
});

// ── API: Campaigns (read-only, for cross-rApp consumption) ──

routes.get("/api/campaigns", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const doc = ensureDoc(dataSpace);
	const campaigns = Object.values(doc.campaigns || {}).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
	return c.json({ campaigns, count: campaigns.length });
});

routes.get("/api/campaigns/:id", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	const doc = ensureDoc(dataSpace);
	const campaign = doc.campaigns?.[id];
	if (!campaign) return c.json({ error: "Campaign not found" }, 404);
	return c.json(campaign);
});

// ── Image API routes (server-side, need filesystem + FAL_KEY) ──

routes.post("/api/threads/:id/image", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);

	const thread = getThreadFromDoc(dataSpace, id);
	if (!thread) return c.json({ error: "Thread not found" }, 404);

	if (!process.env.FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);

	const summary = thread.tweets.slice(0, 3).join(" ").substring(0, 300);
	const prompt = `Abstract artistic illustration evoking the mood and themes of: "${summary}". Style: cinematic dark atmosphere with deep navy and charcoal tones, luminous flowing gradients of electric blue, violet, and teal, organic abstract shapes and flowing forms, subtle geometric patterns, ethereal light rays and bokeh, no text, no words, no letters, no typography, no UI elements. Pure visual art — evocative, atmospheric, and beautiful. Landscape 4:3.`;

	let cdnUrl: string | null;
	try {
		cdnUrl = await generateImageFromPrompt(prompt);
	} catch (e: any) {
		console.error("[rSocials] Thread image generation error:", e.message);
		return c.json({ error: "Image generation failed: " + (e.message || "unknown error") }, 502);
	}
	if (!cdnUrl) return c.json({ error: "Image generation failed" }, 502);

	const filename = `thread-${id}.png`;
	let imageUrl: string | null;
	try {
		imageUrl = await downloadAndSaveImage(cdnUrl, filename);
	} catch (e: any) {
		console.error("[rSocials] Image download error:", e.message);
		return c.json({ error: "Failed to download image: " + (e.message || "unknown error") }, 502);
	}
	if (!imageUrl) return c.json({ error: "Failed to download image" }, 502);

	// Update Automerge doc with image URL
	const docId = socialsDocId(dataSpace);
	_syncServer!.changeDoc<SocialsDoc>(docId, "set thread image", (d) => {
		if (d.threads?.[id]) {
			d.threads[id].imageUrl = imageUrl;
			d.threads[id].updatedAt = Date.now();
		}
	});

	return c.json({ imageUrl });
});

routes.post("/api/threads/:id/upload-image", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);

	const thread = getThreadFromDoc(dataSpace, id);
	if (!thread) return c.json({ error: "Thread not found" }, 404);

	let formData: FormData;
	try { formData = await c.req.formData(); } catch { return c.json({ error: "Invalid form data" }, 400); }

	const file = formData.get("file");
	if (!file || !(file instanceof File)) return c.json({ error: "No file provided" }, 400);

	const err = validateImageFile(file);
	if (err) return c.json({ error: err }, 400);

	const ext = safeExtension(file.name);
	const filename = `thread-${id}.${ext}`;

	if (thread.imageUrl) await deleteOldImage(thread.imageUrl, filename);

	const buffer = Buffer.from(await file.arrayBuffer());
	const imageUrl = await saveUploadedFile(buffer, filename);

	const docId = socialsDocId(dataSpace);
	_syncServer!.changeDoc<SocialsDoc>(docId, "upload thread image", (d) => {
		if (d.threads?.[id]) {
			d.threads[id].imageUrl = imageUrl;
			d.threads[id].updatedAt = Date.now();
		}
	});

	return c.json({ imageUrl });
});

routes.post("/api/threads/:id/tweet/:index/upload-image", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	const index = c.req.param("index");
	if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
	if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400);

	const thread = getThreadFromDoc(dataSpace, id);
	if (!thread) return c.json({ error: "Thread not found" }, 404);

	let formData: FormData;
	try { formData = await c.req.formData(); } catch { return c.json({ error: "Invalid form data" }, 400); }

	const file = formData.get("file");
	if (!file || !(file instanceof File)) return c.json({ error: "No file provided" }, 400);

	const err = validateImageFile(file);
	if (err) return c.json({ error: err }, 400);

	const ext = safeExtension(file.name);
	const filename = `thread-${id}-tweet-${index}.${ext}`;

	const oldUrl = thread.tweetImages?.[index];
	if (oldUrl) await deleteOldImage(oldUrl, filename);

	const buffer = Buffer.from(await file.arrayBuffer());
	const imageUrl = await saveUploadedFile(buffer, filename);

	const docId = socialsDocId(dataSpace);
	_syncServer!.changeDoc<SocialsDoc>(docId, "upload tweet image", (d) => {
		if (d.threads?.[id]) {
			if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any;
			d.threads[id].tweetImages![index] = imageUrl;
			d.threads[id].updatedAt = Date.now();
		}
	});

	return c.json({ imageUrl });
});

routes.post("/api/threads/:id/tweet/:index/image", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	const index = c.req.param("index");
	if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
	if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400);

	const thread = getThreadFromDoc(dataSpace, id);
	if (!thread) return c.json({ error: "Thread not found" }, 404);

	const tweetIndex = parseInt(index, 10);
	if (tweetIndex < 0 || tweetIndex >= thread.tweets.length) {
		return c.json({ error: "Tweet index out of range" }, 400);
	}

	if (!process.env.FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);

	const tweetText = thread.tweets[tweetIndex].substring(0, 300);
	const prompt = `Abstract artistic illustration inspired by the theme: "${tweetText}". Style: moody cinematic lighting on dark background, luminous abstract forms with flowing gradients of electric blue, violet, and warm amber, organic shapes suggesting the concept without being literal, subtle particle effects and light diffusion, no text, no words, no letters, no numbers, no typography whatsoever. Pure atmospheric visual art — stylistic, evocative, gallery-quality. Landscape 4:3.`;

	let cdnUrl: string | null;
	try {
		cdnUrl = await generateImageFromPrompt(prompt);
	} catch (e: any) {
		console.error("[rSocials] Tweet image generation error:", e.message);
		return c.json({ error: "Image generation failed: " + (e.message || "unknown error") }, 502);
	}
	if (!cdnUrl) return c.json({ error: "Image generation failed" }, 502);

	const filename = `thread-${id}-tweet-${index}.png`;

	const oldUrl = thread.tweetImages?.[index];
	if (oldUrl) await deleteOldImage(oldUrl, filename);

	let imageUrl: string | null;
	try {
		imageUrl = await downloadAndSaveImage(cdnUrl, filename);
	} catch (e: any) {
		console.error("[rSocials] Image download error:", e.message);
		return c.json({ error: "Failed to download image: " + (e.message || "unknown error") }, 502);
	}
	if (!imageUrl) return c.json({ error: "Failed to download image" }, 502);

	const docId = socialsDocId(dataSpace);
	_syncServer!.changeDoc<SocialsDoc>(docId, "generate tweet image", (d) => {
		if (d.threads?.[id]) {
			if (!d.threads[id].tweetImages) d.threads[id].tweetImages = {} as any;
			d.threads[id].tweetImages![index] = imageUrl;
			d.threads[id].updatedAt = Date.now();
		}
	});

	return c.json({ imageUrl });
});

routes.delete("/api/threads/:id/tweet/:index/image", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	const index = c.req.param("index");
	if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);
	if (!/^\d+$/.test(index)) return c.json({ error: "Invalid index" }, 400);

	const thread = getThreadFromDoc(dataSpace, id);
	if (!thread) return c.json({ error: "Thread not found" }, 404);

	if (!thread.tweetImages?.[index]) return c.json({ ok: true });

	await deleteImageFile(thread.tweetImages[index]);

	const docId = socialsDocId(dataSpace);
	_syncServer!.changeDoc<SocialsDoc>(docId, "remove tweet image", (d) => {
		if (d.threads?.[id]?.tweetImages?.[index]) {
			delete d.threads[id].tweetImages![index];
			if (Object.keys(d.threads[id].tweetImages || {}).length === 0) {
				delete d.threads[id].tweetImages;
			}
			d.threads[id].updatedAt = Date.now();
		}
	});

	return c.json({ ok: true });
});

routes.delete("/api/threads/:id/images", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	if (!id || id.includes("..") || id.includes("/")) return c.json({ error: "Invalid ID" }, 400);

	const thread = getThreadFromDoc(dataSpace, id);
	if (!thread) return c.json({ ok: true }); // Thread already gone

	// Clean up header image
	if (thread.imageUrl) await deleteImageFile(thread.imageUrl);

	// Clean up per-tweet images
	if (thread.tweetImages) {
		for (const url of Object.values(thread.tweetImages)) {
			await deleteImageFile(url);
		}
	}

	return c.json({ ok: true });
});

// ── Newsletter (Listmonk) proxy API ──

async function requireNewsletterRole(c: any, minRole: SpaceRoleString): Promise<{ claims: EncryptIDClaims; role: SpaceRoleString } | Response> {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	let claims: EncryptIDClaims;
	try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const result = await resolveCallerRole(space, claims);
	if (!result || !roleAtLeast(result.role, minRole)) {
		return c.json({ error: "Insufficient permissions" }, 403);
	}
	return { claims, role: result.role };
}

routes.get("/api/newsletter/status", async (c) => {
	const space = c.req.param("space") || "demo";
	const config = await getListmonkConfig(space);
	return c.json({ configured: !!config });
});

routes.get("/api/newsletter/lists", async (c) => {
	const auth = await requireNewsletterRole(c, "moderator");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const config = await getListmonkConfig(space);
	if (!config) return c.json({ error: "Listmonk not configured" }, 404);

	const res = await listmonkFetch(config, "/api/lists");
	const data = await res.json();
	return c.json(data, res.status as any);
});

routes.get("/api/newsletter/subscribers", async (c) => {
	const auth = await requireNewsletterRole(c, "moderator");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const config = await getListmonkConfig(space);
	if (!config) return c.json({ error: "Listmonk not configured" }, 404);

	const url = new URL(c.req.url);
	const query = url.search || "";
	const res = await listmonkFetch(config, `/api/subscribers${query}`);
	const data = await res.json();
	return c.json(data, res.status as any);
});

routes.get("/api/newsletter/campaigns", async (c) => {
	const auth = await requireNewsletterRole(c, "moderator");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const config = await getListmonkConfig(space);
	if (!config) return c.json({ error: "Listmonk not configured" }, 404);

	const res = await listmonkFetch(config, "/api/campaigns");
	const data = await res.json();
	return c.json(data, res.status as any);
});

routes.post("/api/newsletter/campaigns", async (c) => {
	const auth = await requireNewsletterRole(c, "admin");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const config = await getListmonkConfig(space);
	if (!config) return c.json({ error: "Listmonk not configured" }, 404);

	const body = await c.req.text();
	const res = await listmonkFetch(config, "/api/campaigns", { method: "POST", body });
	const data = await res.json();
	return c.json(data, res.status as any);
});

routes.put("/api/newsletter/campaigns/:id/status", async (c) => {
	const auth = await requireNewsletterRole(c, "moderator");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const config = await getListmonkConfig(space);
	if (!config) return c.json({ error: "Listmonk not configured" }, 404);

	const campaignId = c.req.param("id");
	const body = await c.req.text();
	const res = await listmonkFetch(config, `/api/campaigns/${campaignId}/status`, { method: "PUT", body });
	const data = await res.json();
	return c.json(data, res.status as any);
});

// Single campaign CRUD
routes.get("/api/newsletter/campaigns/:id", async (c) => {
	const auth = await requireNewsletterRole(c, "moderator");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const config = await getListmonkConfig(space);
	if (!config) return c.json({ error: "Listmonk not configured" }, 404);

	const campaignId = c.req.param("id");
	const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`);
	const data = await res.json();
	return c.json(data, res.status as any);
});

routes.put("/api/newsletter/campaigns/:id", async (c) => {
	const auth = await requireNewsletterRole(c, "admin");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const config = await getListmonkConfig(space);
	if (!config) return c.json({ error: "Listmonk not configured" }, 404);

	const campaignId = c.req.param("id");
	const body = await c.req.text();
	const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`, { method: "PUT", body });
	const data = await res.json();
	return c.json(data, res.status as any);
});

routes.delete("/api/newsletter/campaigns/:id", async (c) => {
	const auth = await requireNewsletterRole(c, "admin");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const config = await getListmonkConfig(space);
	if (!config) return c.json({ error: "Listmonk not configured" }, 404);

	const campaignId = c.req.param("id");
	const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`, { method: "DELETE" });
	if (res.status === 200 || res.status === 204) return c.json({ ok: true });
	const data = await res.json().catch(() => ({}));
	return c.json(data, res.status as any);
});

// ── Newsletter Drafts (Automerge-backed) ──

routes.get("/api/newsletter/drafts", async (c) => {
	const auth = await requireNewsletterRole(c, "moderator");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const dataSpace = (c as any).get("effectiveSpace") || space;
	const doc = ensureDoc(dataSpace);
	const drafts = Object.values(doc.newsletterDrafts || {}).sort((a, b) => b.updatedAt - a.updatedAt);
	return c.json({ drafts });
});

routes.post("/api/newsletter/drafts", async (c) => {
	const auth = await requireNewsletterRole(c, "admin");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const dataSpace = (c as any).get("effectiveSpace") || space;
	const body = await c.req.json<{ title?: string; subject?: string; body?: string }>();
	if (!body.title || !body.subject) return c.json({ error: "title and subject required" }, 400);

	const id = crypto.randomUUID();
	const now = Date.now();
	const draft: NewsletterDraft = {
		id,
		title: body.title,
		subject: body.subject,
		body: body.body || '',
		status: 'draft',
		subscribers: [],
		createdAt: now,
		updatedAt: now,
		createdBy: auth.claims.did as string,
	};

	const docId = socialsDocId(dataSpace);
	_syncServer!.changeDoc<SocialsDoc>(docId, `create newsletter draft ${id}`, (d) => {
		if (!d.newsletterDrafts) d.newsletterDrafts = {} as any;
		d.newsletterDrafts[id] = draft;
	});
	return c.json({ draft }, 201);
});

routes.get("/api/newsletter/drafts/:draftId", async (c) => {
	const auth = await requireNewsletterRole(c, "moderator");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const dataSpace = (c as any).get("effectiveSpace") || space;
	const draftId = c.req.param("draftId");
	const doc = ensureDoc(dataSpace);
	const draft = doc.newsletterDrafts?.[draftId];
	if (!draft) return c.json({ error: "Draft not found" }, 404);
	return c.json({ draft });
});

routes.put("/api/newsletter/drafts/:draftId", async (c) => {
	const auth = await requireNewsletterRole(c, "admin");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const dataSpace = (c as any).get("effectiveSpace") || space;
	const draftId = c.req.param("draftId");
	const doc = ensureDoc(dataSpace);
	if (!doc.newsletterDrafts?.[draftId]) return c.json({ error: "Draft not found" }, 404);

	const body = await c.req.json<{ title?: string; subject?: string; body?: string; status?: string; subscribers?: { email: string; name?: string }[] }>();
	const docId = socialsDocId(dataSpace);
	_syncServer!.changeDoc<SocialsDoc>(docId, `update newsletter draft ${draftId}`, (d) => {
		const draft = d.newsletterDrafts[draftId];
		if (body.title !== undefined) draft.title = body.title;
		if (body.subject !== undefined) draft.subject = body.subject;
		if (body.body !== undefined) draft.body = body.body;
		if (body.status === 'draft' || body.status === 'ready' || body.status === 'sent') draft.status = body.status;
		if (body.subscribers) {
			draft.subscribers = body.subscribers.map(s => ({
				email: s.email,
				name: s.name || '',
				addedAt: Date.now(),
			})) as any;
		}
		draft.updatedAt = Date.now();
	});

	const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
	return c.json({ draft: updated.newsletterDrafts[draftId] });
});

routes.delete("/api/newsletter/drafts/:draftId", async (c) => {
	const auth = await requireNewsletterRole(c, "admin");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const dataSpace = (c as any).get("effectiveSpace") || space;
	const draftId = c.req.param("draftId");
	const doc = ensureDoc(dataSpace);
	if (!doc.newsletterDrafts?.[draftId]) return c.json({ error: "Draft not found" }, 404);

	const docId = socialsDocId(dataSpace);
	_syncServer!.changeDoc<SocialsDoc>(docId, `delete newsletter draft ${draftId}`, (d) => {
		delete d.newsletterDrafts[draftId];
	});
	return c.json({ ok: true });
});

// ── Newsletter Approval Bridge ──

routes.post("/api/newsletter/campaigns/:id/submit-approval", async (c) => {
	const auth = await requireNewsletterRole(c, "moderator");
	if (auth instanceof Response) return auth;

	const space = c.req.param("space") || "demo";
	const config = await getListmonkConfig(space);
	if (!config) return c.json({ error: "Listmonk not configured" }, 404);

	const campaignId = parseInt(c.req.param("id"));
	if (!campaignId) return c.json({ error: "Invalid campaign ID" }, 400);

	// Fetch campaign content for preview
	const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`);
	if (!res.ok) return c.json({ error: "Campaign not found in Listmonk" }, 404);
	const campaign = await res.json() as { data?: { subject?: string; body?: string; name?: string } };
	const subject = campaign.data?.subject || campaign.data?.name || `Campaign #${campaignId}`;
	const body = campaign.data?.body || '';

	const { createNewsletterApproval } = await import('../../modules/rinbox/mod');
	const result = createNewsletterApproval({
		space,
		authorId: auth.claims.did as string,
		subject,
		bodyText: body.replace(/<[^>]+>/g, ''),
		bodyHtml: body,
		listmonkCampaignId: campaignId,
	});

	if (!result) return c.json({ error: "No team inbox configured for this space" }, 404);

	return c.json({ ok: true, approval_id: result.id, required_signatures: result.requiredSignatures });
});

// ── Postiz API proxy routes ──

routes.get("/api/postiz/status", async (c) => {
	const space = c.req.param("space") || "demo";
	const config = await getPostizConfig(space);
	return c.json({ configured: !!config });
});

routes.get("/api/postiz/integrations", async (c) => {
	const space = c.req.param("space") || "demo";
	const config = await getPostizConfig(space);
	if (!config) return c.json({ error: "Postiz not configured" }, 404);

	try {
		const data = await getIntegrations(config);
		return c.json(data);
	} catch (err: any) {
		return c.json({ error: err.message }, 502);
	}
});

routes.post("/api/postiz/posts", async (c) => {
	const space = c.req.param("space") || "demo";
	const config = await getPostizConfig(space);
	if (!config) return c.json({ error: "Postiz not configured" }, 404);

	const body = await c.req.json();
	const { content, integrationIds, type, scheduledAt } = body;
	if (!content || !integrationIds?.length) {
		return c.json({ error: "content and integrationIds are required" }, 400);
	}

	try {
		const result = await createPost(config, {
			content,
			integrationIds,
			type: type || 'draft',
			scheduledAt,
		});
		return c.json(result);
	} catch (err: any) {
		return c.json({ error: err.message }, 502);
	}
});

routes.post("/api/postiz/threads", async (c) => {
	const space = c.req.param("space") || "demo";
	const config = await getPostizConfig(space);
	if (!config) return c.json({ error: "Postiz not configured" }, 404);

	const body = await c.req.json();
	const { tweets, integrationIds, type, scheduledAt } = body;
	if (!tweets?.length) {
		return c.json({ error: "tweets array is required" }, 400);
	}

	// If no integrationIds provided, try to auto-detect from configured integrations
	let ids = integrationIds;
	if (!ids?.length) {
		try {
			const integrations = await getIntegrations(config);
			ids = (integrations || []).map((i: any) => i.id).slice(0, 1);
		} catch { /* fall through */ }
	}
	if (!ids?.length) {
		return c.json({ error: "No integrationIds provided and no integrations found" }, 400);
	}

	try {
		const result = await createThread(config, tweets, {
			integrationIds: ids,
			type: type || 'draft',
			scheduledAt,
		});
		return c.json(result);
	} catch (err: any) {
		return c.json({ error: err.message }, 502);
	}
});

// ── Approval queue routes ──

routes.get("/api/approvals", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const doc = ensureDoc(dataSpace);
	const approvals = Object.values(doc.pendingApprovals || {})
		.filter(a => a.status === 'pending')
		.sort((a, b) => b.createdAt - a.createdAt);
	return c.json(approvals);
});

routes.post("/api/approvals/:id/resolve", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const approvalId = c.req.param("id");
	const body = await c.req.json();
	const action = body.action as 'approve' | 'reject';

	if (!action || !['approve', 'reject'].includes(action)) {
		return c.json({ error: "action must be 'approve' or 'reject'" }, 400);
	}

	const docId = socialsDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	const approval = doc.pendingApprovals?.[approvalId];
	if (!approval) return c.json({ error: "Approval not found" }, 404);
	if (approval.status !== 'pending') return c.json({ error: "Approval already resolved" }, 409);

	_syncServer!.changeDoc<SocialsDoc>(docId, `resolve approval ${approvalId}`, (d) => {
		const a = d.pendingApprovals[approvalId];
		if (!a) return;
		a.status = action === 'approve' ? 'approved' : 'rejected';
		a.resolvedAt = Date.now();
	});

	return c.json({ ok: true, status: action === 'approve' ? 'approved' : 'rejected' });
});

// ── AI Campaign Generator ──

routes.post("/api/campaign/generate", async (c) => {
	const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

	const { brief, platforms, style, tone } = await c.req.json();
	if (!brief || typeof brief !== "string" || brief.trim().length < 10) {
		return c.json({ error: "brief is required (min 10 characters)" }, 400);
	}

	const selectedPlatforms = (platforms && Array.isArray(platforms) && platforms.length > 0)
		? platforms : ["x", "linkedin", "instagram", "youtube", "threads", "bluesky", "newsletter"];
	const campaignStyle = style || "event-promo";
	const campaignTone = tone || "professional";

	const { GoogleGenerativeAI } = await import("@google/generative-ai");
	const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
	const model = genAI.getGenerativeModel({ model: "gemini-2.5-pro" });

	const systemPrompt = `You are an expert social media campaign strategist. Given an event brief, generate a complete multi-phase, multi-platform social media campaign.

Style: ${campaignStyle}
Tone: ${campaignTone}
Target platforms: ${selectedPlatforms.join(", ")}

Platform specifications:
- x: Max 280 chars per post. Support threads (threadPosts array). Use 2-4 hashtags, emojis encouraged.
- linkedin: Max 1300 chars. Professional tone. 3-5 hashtags. No emojis in professional mode.
- instagram: Carousel descriptions. 20-30 hashtags. Heavy emoji usage.
- youtube: Video title + description. SEO-focused with keywords.
- threads: Max 500 chars. Casual tone. Support threads (threadPosts array).
- bluesky: Max 300 chars. Conversational. Minimal hashtags (1-2).
- newsletter: HTML email body (emailHtml) with subject line (emailSubject). Include sections, CTA button.

Event brief:
"""
${brief.trim()}
"""

Return ONLY valid JSON (no markdown fences):
{
  "title": "Campaign title",
  "description": "1-2 sentence campaign description",
  "duration": "Human-readable date range (e.g. 'Mar 20-25, 2026 (6 days)')",
  "platforms": [${selectedPlatforms.map((p: string) => `"${p}"`).join(", ")}],
  "phases": [
    { "name": "phase-slug", "label": "Phase Label", "days": "Day -3 to -1" }
  ],
  "posts": [
    {
      "platform": "x",
      "postType": "thread",
      "stepNumber": 1,
      "content": "Main post content with emojis and formatting",
      "scheduledAt": "2026-03-20T09:00:00",
      "status": "draft",
      "hashtags": ["Tag1", "Tag2"],
      "phase": 1,
      "phaseLabel": "Phase Label",
      "threadPosts": ["First tweet of thread", "Second tweet", "Third tweet"],
      "emailSubject": null,
      "emailHtml": null
    }
  ]
}

Rules:
- Generate 3-5 phases based on event type (pre-launch, launch, amplification, follow-up, etc.)
- Create posts for EACH selected platform in each relevant phase (not every platform needs every phase)
- For X and Threads posts marked as "thread" postType, include threadPosts array with individual posts
- For newsletter posts, include emailSubject and emailHtml with proper HTML (inline styles, CTA button)
- Use realistic future scheduledAt dates based on the brief
- stepNumber should increment across ALL posts (global ordering)
- Each post's content must respect the platform's character limits
- Include relevant emojis naturally in post content
- Hashtags should be relevant, no # prefix in the array
- Make content engaging, not generic — reference specific details from the brief`;

	try {
		const result = await model.generateContent(systemPrompt);
		const text = result.response.text();
		const jsonStr = text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
		const generated = JSON.parse(jsonStr);

		// Add IDs and timestamps
		const now = Date.now();
		const campaignId = `gen-${now}`;
		const campaign = {
			id: campaignId,
			title: generated.title || "Generated Campaign",
			description: generated.description || "",
			duration: generated.duration || "",
			platforms: generated.platforms || selectedPlatforms,
			phases: generated.phases || [],
			posts: (generated.posts || []).map((p: any, i: number) => ({
				id: `${campaignId}-post-${i}`,
				platform: p.platform || "x",
				postType: p.postType || "text",
				stepNumber: p.stepNumber || i + 1,
				content: p.content || "",
				scheduledAt: p.scheduledAt || new Date().toISOString(),
				status: p.status || "draft",
				hashtags: p.hashtags || [],
				phase: p.phase || 1,
				phaseLabel: p.phaseLabel || "",
				...(p.threadPosts ? { threadPosts: p.threadPosts } : {}),
				...(p.emailSubject ? { emailSubject: p.emailSubject } : {}),
				...(p.emailHtml ? { emailHtml: p.emailHtml } : {}),
			})),
			createdAt: now,
			updatedAt: now,
		};

		return c.json(campaign);
	} catch (e: any) {
		console.error("[rSocials] Campaign generation error:", e.message);
		return c.json({ error: "Failed to generate campaign: " + (e.message || "unknown error") }, 502);
	}
});

// ── Campaign Flow CRUD API (Automerge-backed, powers the dashboard) ──

routes.get("/api/campaign/flows", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const doc = ensureDoc(dataSpace);
	const flows = Object.values(doc.campaignFlows || {});
	flows.sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
	return c.json({ count: flows.length, results: flows });
});

routes.post("/api/campaign/flows", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const body = await c.req.json().catch(() => ({}));

	const docId = socialsDocId(dataSpace);
	ensureDoc(dataSpace);
	const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
	const now = Date.now();

	const flow: CampaignFlow = {
		id: flowId,
		name: body.name || "New Campaign",
		nodes: body.nodes || [],
		edges: body.edges || [],
		createdAt: now,
		updatedAt: now,
		createdBy: null,
	};

	_syncServer!.changeDoc<SocialsDoc>(docId, `create campaign flow ${flowId}`, (d) => {
		if (!d.campaignFlows) d.campaignFlows = {} as any;
		(d.campaignFlows as any)[flowId] = flow;
		d.activeFlowId = flowId;
	});

	const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
	return c.json(updated.campaignFlows[flowId], 201);
});

routes.delete("/api/campaign/flows/:id", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");

	const docId = socialsDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	if (!doc.campaignFlows?.[id]) return c.json({ error: "Campaign flow not found" }, 404);

	_syncServer!.changeDoc<SocialsDoc>(docId, `delete campaign flow ${id}`, (d) => {
		delete d.campaignFlows[id];
		if (d.activeFlowId === id) d.activeFlowId = '';
	});

	return c.json({ ok: true });
});

// ── Campaign Workflow CRUD API ──

routes.get("/api/campaign-workflows", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const doc = ensureDoc(dataSpace);
	const workflows = Object.values(doc.campaignWorkflows || {});
	workflows.sort((a, b) => a.name.localeCompare(b.name));
	return c.json({ count: workflows.length, results: workflows });
});

routes.post("/api/campaign-workflows", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const body = await c.req.json();

	const docId = socialsDocId(dataSpace);
	ensureDoc(dataSpace);
	const wfId = crypto.randomUUID();
	const now = Date.now();

	const workflow: CampaignWorkflow = {
		id: wfId,
		name: body.name || "New Campaign Workflow",
		enabled: body.enabled !== false,
		nodes: body.nodes || [],
		edges: body.edges || [],
		lastRunAt: null,
		lastRunStatus: null,
		runCount: 0,
		createdAt: now,
		updatedAt: now,
	};

	_syncServer!.changeDoc<SocialsDoc>(docId, `create campaign workflow ${wfId}`, (d) => {
		if (!d.campaignWorkflows) d.campaignWorkflows = {} as any;
		(d.campaignWorkflows as any)[wfId] = workflow;
	});

	const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
	return c.json(updated.campaignWorkflows[wfId], 201);
});

routes.get("/api/campaign-workflows/:id", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	const doc = ensureDoc(dataSpace);

	const wf = doc.campaignWorkflows?.[id];
	if (!wf) return c.json({ error: "Campaign workflow not found" }, 404);
	return c.json(wf);
});

routes.put("/api/campaign-workflows/:id", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	const body = await c.req.json();

	const docId = socialsDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	if (!doc.campaignWorkflows?.[id]) return c.json({ error: "Campaign workflow not found" }, 404);

	_syncServer!.changeDoc<SocialsDoc>(docId, `update campaign workflow ${id}`, (d) => {
		const wf = d.campaignWorkflows[id];
		if (!wf) return;
		if (body.name !== undefined) wf.name = body.name;
		if (body.enabled !== undefined) wf.enabled = body.enabled;
		if (body.nodes !== undefined) {
			while (wf.nodes.length > 0) wf.nodes.splice(0, 1);
			for (const n of body.nodes) wf.nodes.push(n);
		}
		if (body.edges !== undefined) {
			while (wf.edges.length > 0) wf.edges.splice(0, 1);
			for (const e of body.edges) wf.edges.push(e);
		}
		wf.updatedAt = Date.now();
	});

	const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
	return c.json(updated.campaignWorkflows[id]);
});

routes.delete("/api/campaign-workflows/:id", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");

	const docId = socialsDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	if (!doc.campaignWorkflows?.[id]) return c.json({ error: "Campaign workflow not found" }, 404);

	_syncServer!.changeDoc<SocialsDoc>(docId, `delete campaign workflow ${id}`, (d) => {
		delete d.campaignWorkflows[id];
	});

	return c.json({ ok: true });
});

// POST /api/campaign-workflows/:id/run — manual execute (stub)
routes.post("/api/campaign-workflows/:id/run", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");

	const doc = ensureDoc(dataSpace);
	const wf = doc.campaignWorkflows?.[id];
	if (!wf) return c.json({ error: "Campaign workflow not found" }, 404);

	// Execute workflow — topological walk with real Postiz integration
	const results: { nodeId: string; status: string; message: string; durationMs: number }[] = [];
	const sorted = topologicalSortCampaign(wf.nodes, wf.edges);
	const postizConfig = await getPostizConfig(dataSpace);
	let hasError = false;
	let paused = false;

	for (const node of sorted) {
		if (paused) break;
		const start = Date.now();
		const cfg = node.config || {};

		try {
			switch (node.type) {
				case 'post-to-platform': {
					if (!postizConfig) throw new Error('Postiz not configured');
					const integrations = await getIntegrations(postizConfig);
					const platformName = (cfg.platform as string || '').toLowerCase();
					const match = (integrations || []).find((i: any) =>
						i.name?.toLowerCase().includes(platformName) || i.providerIdentifier?.toLowerCase().includes(platformName)
					);
					const integrationIds = match ? [match.id] : (integrations || []).slice(0, 1).map((i: any) => i.id);
					const content = (cfg.content as string || '') + (cfg.hashtags ? '\n' + cfg.hashtags : '');
					await createPost(postizConfig, { content, integrationIds, type: 'draft' });
					results.push({ nodeId: node.id, status: 'success', message: `Draft created on ${cfg.platform || 'default'}`, durationMs: Date.now() - start });
					break;
				}
				case 'cross-post': {
					if (!postizConfig) throw new Error('Postiz not configured');
					const platforms = (cfg.platforms as string || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
					const integrations = await getIntegrations(postizConfig);
					const content = (cfg.content as string || '');
					const posted: string[] = [];
					for (const plat of platforms) {
						const match = (integrations || []).find((i: any) =>
							i.name?.toLowerCase().includes(plat) || i.providerIdentifier?.toLowerCase().includes(plat)
						);
						if (match) {
							await createPost(postizConfig, { content, integrationIds: [match.id], type: 'draft' });
							posted.push(plat);
						}
					}
					results.push({ nodeId: node.id, status: 'success', message: `Cross-posted draft to: ${posted.join(', ') || 'none matched'}`, durationMs: Date.now() - start });
					break;
				}
				case 'publish-thread': {
					if (!postizConfig) throw new Error('Postiz not configured');
					let threadContent = cfg.threadContent as string || '';
					if (!threadContent && cfg.threadId) {
						const linkedThread = doc.threads?.[cfg.threadId as string];
						if (linkedThread?.tweets?.length) {
							threadContent = linkedThread.tweets.join('\n---\n');
						}
					}
					const tweets = threadContent.split(/\n---\n/).map(s => s.trim()).filter(Boolean);
					if (tweets.length === 0) throw new Error('No thread content or thread ID provided');
					const integrations = await getIntegrations(postizConfig);
					const platformName = (cfg.platform as string || '').toLowerCase();
					const match = (integrations || []).find((i: any) =>
						i.name?.toLowerCase().includes(platformName) || i.providerIdentifier?.toLowerCase().includes(platformName)
					);
					const integrationIds = match ? [match.id] : (integrations || []).slice(0, 1).map((i: any) => i.id);
					await createThread(postizConfig, tweets, { integrationIds, type: 'draft' });
					results.push({ nodeId: node.id, status: 'success', message: `Thread draft created (${tweets.length} tweets)`, durationMs: Date.now() - start });
					break;
				}
				case 'send-newsletter': {
					const listmonkConfig = await getListmonkConfig(dataSpace);
					if (!listmonkConfig) {
						results.push({ nodeId: node.id, status: 'success', message: '[listmonk] Not configured — skipped', durationMs: Date.now() - start });
						break;
					}
					// Create Listmonk campaign as draft
					const nlSubject = (cfg.subject as string) || wf.name || 'Newsletter';
					const nlBody = (cfg.body as string) || '';
					const nlListIds = Array.isArray(cfg.listIds) ? cfg.listIds as number[] : [1];
					const createRes = await listmonkFetch(listmonkConfig, '/api/campaigns', {
						method: 'POST',
						body: JSON.stringify({
							name: nlSubject,
							subject: nlSubject,
							body: nlBody || '<p>Newsletter content</p>',
							content_type: 'richtext',
							type: 'regular',
							lists: nlListIds,
							status: 'draft',
						}),
					});
					if (!createRes.ok) {
						const err = await createRes.json().catch(() => ({}));
						throw new Error(`Listmonk campaign create failed: ${(err as any).message || createRes.status}`);
					}
					const campaignData = await createRes.json() as { data?: { id?: number } };
					const campaignId = campaignData.data?.id;
					if (!campaignId) throw new Error('Listmonk returned no campaign ID');

					// Try to gate via rInbox approval
					const { createNewsletterApproval } = await import('../../modules/rinbox/mod');
					const approvalResult = createNewsletterApproval({
						space: dataSpace,
						authorId: 'workflow',
						subject: nlSubject,
						bodyText: nlBody,
						bodyHtml: nlBody,
						listmonkCampaignId: campaignId,
					});

					if (approvalResult) {
						// Pause workflow — approval required
						results.push({ nodeId: node.id, status: 'paused', message: `Newsletter campaign #${campaignId} awaiting approval (${approvalResult.id})`, durationMs: Date.now() - start });
						paused = true;
					} else {
						// No team inbox → start immediately
						const { startListmonkCampaign } = await import('./lib/listmonk-proxy');
						const sendResult = await startListmonkCampaign(listmonkConfig, campaignId);
						if (!sendResult.ok) throw new Error(sendResult.error || 'Failed to start campaign');
						results.push({ nodeId: node.id, status: 'success', message: `Newsletter campaign #${campaignId} started (no approval gate)`, durationMs: Date.now() - start });
					}
					break;
				}
				case 'post-webhook': {
					const webhookUrl = cfg.url as string;
					if (!webhookUrl) throw new Error('No webhook URL configured');
					const bodyTemplate = cfg.bodyTemplate as string || '{}';
					const res = await fetch(webhookUrl, {
						method: 'POST',
						headers: { 'Content-Type': 'application/json' },
						body: bodyTemplate,
					});
					results.push({ nodeId: node.id, status: res.ok ? 'success' : 'error', message: `Webhook ${res.status}`, durationMs: Date.now() - start });
					if (!res.ok) hasError = true;
					break;
				}
				case 'wait-approval': {
					// Create a pending approval and pause execution
					const docId = socialsDocId(dataSpace);
					const approvalId = `apr-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`;
					_syncServer!.changeDoc<SocialsDoc>(docId, `create approval for ${node.id}`, (d) => {
						if (!d.pendingApprovals) (d as any).pendingApprovals = {};
						d.pendingApprovals[approvalId] = {
							id: approvalId,
							workflowId: id,
							nodeId: node.id,
							message: (cfg.message as string) || 'Approval required',
							approver: (cfg.approver as string) || '',
							status: 'pending',
							createdAt: Date.now(),
							resolvedAt: null,
						};
					});
					results.push({ nodeId: node.id, status: 'paused', message: `Awaiting approval (${approvalId})`, durationMs: Date.now() - start });
					paused = true;
					break;
				}
				default: {
					// Triggers, conditions, delays — pass through
					results.push({ nodeId: node.id, status: 'success', message: `${node.label} passed`, durationMs: Date.now() - start });
					break;
				}
			}
		} catch (err: any) {
			hasError = true;
			results.push({ nodeId: node.id, status: 'error', message: err.message, durationMs: Date.now() - start });
		}
	}

	// Update run metadata
	const docId = socialsDocId(dataSpace);
	_syncServer!.changeDoc<SocialsDoc>(docId, `run campaign workflow ${id}`, (d) => {
		const w = d.campaignWorkflows[id];
		if (!w) return;
		w.lastRunAt = Date.now();
		w.lastRunStatus = hasError ? 'error' : 'success';
		w.runCount = (w.runCount || 0) + 1;
	});

	return c.json({ results, paused });
});

// POST /api/campaign-workflows/webhook/:hookId — external webhook trigger
routes.post("/api/campaign-workflows/webhook/:hookId", async (c) => {
	const hookId = c.req.param("hookId");
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;

	const doc = ensureDoc(dataSpace);
	// Find workflow containing a webhook-trigger node with this hookId
	for (const wf of Object.values(doc.campaignWorkflows || {})) {
		if (!wf.enabled) continue;
		const hookNode = wf.nodes.find(n =>
			n.type === 'webhook-trigger' && n.config.hookId === hookId
		);
		if (hookNode) {
			return c.json({ ok: true, workflowId: wf.id, message: 'Webhook received (stub — execution not implemented yet)' });
		}
	}
	return c.json({ error: "No matching webhook trigger found" }, 404);
});

function topologicalSortCampaign(nodes: CampaignWorkflowNode[], edges: CampaignWorkflowEdge[]): CampaignWorkflowNode[] {
	const adj = new Map<string, string[]>();
	const inDegree = new Map<string, number>();

	for (const n of nodes) {
		adj.set(n.id, []);
		inDegree.set(n.id, 0);
	}

	for (const e of edges) {
		adj.get(e.fromNode)?.push(e.toNode);
		inDegree.set(e.toNode, (inDegree.get(e.toNode) || 0) + 1);
	}

	const queue: string[] = [];
	for (const [id, deg] of inDegree) {
		if (deg === 0) queue.push(id);
	}

	const sorted: CampaignWorkflowNode[] = [];
	while (queue.length > 0) {
		const id = queue.shift()!;
		const node = nodes.find(n => n.id === id);
		if (node) sorted.push(node);
		for (const next of adj.get(id) || []) {
			const d = (inDegree.get(next) || 1) - 1;
			inDegree.set(next, d);
			if (d === 0) queue.push(next);
		}
	}

	return sorted;
}

// ── Campaign Wizard API ──

routes.post("/api/campaign/wizard", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const body = await c.req.json().catch(() => ({}));
	const docId = socialsDocId(dataSpace);
	ensureDoc(dataSpace);

	const wizardId = `wiz-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`;
	const now = Date.now();
	const wizard: CampaignWizard = {
		id: wizardId,
		step: 'brief',
		rawBrief: body.rawBrief || '',
		extractedBrief: null,
		structure: null,
		campaignDraft: null,
		committedCampaignId: null,
		createdAt: now,
		updatedAt: now,
		createdBy: null,
	};

	_syncServer!.changeDoc<SocialsDoc>(docId, `create campaign wizard ${wizardId}`, (d) => {
		if (!d.campaignWizards) d.campaignWizards = {} as any;
		(d.campaignWizards as any)[wizardId] = wizard;
	});

	return c.json({ wizardId, url: c.get("isSubdomain") ? `/rsocials/campaign-wizard/${wizardId}` : `/${space}/rsocials/campaign-wizard/${wizardId}` }, 201);
});

routes.get("/api/campaign/wizard/:id", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	const doc = ensureDoc(dataSpace);
	const wizard = doc.campaignWizards?.[id];
	if (!wizard) return c.json({ error: "Wizard not found" }, 404);
	return c.json(wizard);
});

routes.post("/api/campaign/wizard/:id/structure", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

	const docId = socialsDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	const wizard = doc.campaignWizards?.[id];
	if (!wizard) return c.json({ error: "Wizard not found" }, 404);

	const body = await c.req.json().catch(() => ({}));
	const rawBrief = body.rawBrief || wizard.rawBrief;
	if (!rawBrief || rawBrief.trim().length < 10) {
		return c.json({ error: "Brief is required (min 10 characters)" }, 400);
	}

	const selectedPlatforms: string[] = Array.isArray(body.selectedPlatforms) && body.selectedPlatforms.length > 0
		? body.selectedPlatforms
		: null;
	const platformConstraint = selectedPlatforms
		? `\n- ONLY use these platforms: ${selectedPlatforms.join(', ')}. Do NOT include any other platforms.`
		: '';

	const { GoogleGenerativeAI } = await import("@google/generative-ai");
	const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
	const model = genAI.getGenerativeModel({
		model: "gemini-2.5-flash",
		generationConfig: { responseMimeType: "application/json" } as any,
	});

	const prompt = `You are a social media campaign strategist. Analyze this campaign brief and extract structured data.

Brief:
"""
${rawBrief.trim()}
"""

Return JSON with this exact shape:
{
  "extractedBrief": {
    "title": "Campaign title derived from the brief",
    "audience": "Target audience description",
    "startDate": "YYYY-MM-DD (infer from brief or use 7 days from now)",
    "endDate": "YYYY-MM-DD (infer from brief or use 30 days from start)",
    "platforms": ["x", "linkedin", "instagram", "threads", "bluesky", "newsletter"],
    "tone": "professional | casual | urgent | inspirational",
    "style": "event-promo | product-launch | awareness | community | educational",
    "keyMessages": ["Key message 1", "Key message 2", "Key message 3"]
  },
  "structure": {
    "phases": [
      {
        "name": "phase-slug",
        "label": "Phase Label",
        "days": "Day 1-3",
        "platforms": ["x", "linkedin"],
        "cadence": { "x": 3, "linkedin": 2 }
      }
    ],
    "summary": "One paragraph summary of the campaign strategy"
  }
}

Rules:
- Generate 3-5 phases (pre-launch, launch, amplification, follow-up, etc.)
- Cadence = number of posts per platform for that phase
- Platforms should be realistic for the brief content${platformConstraint}
- Use today's date (${new Date().toISOString().split('T')[0]}) as reference for dates`;

	try {
		const result = await model.generateContent(prompt);
		const text = result.response.text();
		const parsed = JSON.parse(text);

		// Safety net: filter platforms and cadences to only selected platforms
		if (selectedPlatforms && parsed.extractedBrief) {
			parsed.extractedBrief.platforms = (parsed.extractedBrief.platforms || [])
				.filter((p: string) => selectedPlatforms.includes(p));
			if (parsed.extractedBrief.platforms.length === 0) {
				parsed.extractedBrief.platforms = selectedPlatforms;
			}
		}
		if (selectedPlatforms && parsed.structure?.phases) {
			for (const phase of parsed.structure.phases) {
				phase.platforms = (phase.platforms || []).filter((p: string) => selectedPlatforms.includes(p));
				if (phase.cadence) {
					for (const key of Object.keys(phase.cadence)) {
						if (!selectedPlatforms.includes(key)) delete phase.cadence[key];
					}
				}
			}
		}

		_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} → structure`, (d) => {
			const w = d.campaignWizards?.[id];
			if (!w) return;
			w.rawBrief = rawBrief;
			w.extractedBrief = parsed.extractedBrief || null;
			w.structure = parsed.structure || null;
			w.step = 'structure';
			w.updatedAt = Date.now();
		});

		const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
		return c.json(updated.campaignWizards[id]);
	} catch (e: any) {
		console.error("[rSocials] Wizard structure error:", e.message);
		return c.json({ error: "Failed to analyze brief: " + (e.message || "unknown") }, 502);
	}
});

routes.post("/api/campaign/wizard/:id/content", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

	const docId = socialsDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	const wizard = doc.campaignWizards?.[id];
	if (!wizard) return c.json({ error: "Wizard not found" }, 404);
	if (!wizard.extractedBrief || !wizard.structure) {
		return c.json({ error: "Must complete structure step first" }, 400);
	}

	const body = await c.req.json().catch(() => ({}));
	const phaseInstructions: Record<string, string> = body.phaseInstructions || {};

	const brief = wizard.extractedBrief;
	const structure = wizard.structure;
	const selectedPlatforms = brief.platforms.length > 0 ? brief.platforms : ["x", "linkedin", "instagram", "newsletter"];

	// Build phase-specific instruction hints
	const phaseHints = structure.phases.map((p, i) => {
		const hint = phaseInstructions[String(i)];
		return hint ? `- Phase ${i + 1} (${p.label}): ${hint}` : '';
	}).filter(Boolean).join('\n');

	const { GoogleGenerativeAI } = await import("@google/generative-ai");
	const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
	const model = genAI.getGenerativeModel({
		model: "gemini-2.5-flash",
		generationConfig: { responseMimeType: "application/json" } as any,
	});

	const prompt = `You are an expert social media campaign strategist. Generate a complete campaign with posts for every phase and platform.

Campaign Brief:
- Title: ${brief.title}
- Audience: ${brief.audience}
- Dates: ${brief.startDate} to ${brief.endDate}
- Tone: ${brief.tone}
- Style: ${brief.style}
- Key Messages: ${brief.keyMessages.join('; ')}
- Platforms: ${selectedPlatforms.join(', ')}

Approved Structure:
${JSON.stringify(structure.phases, null, 2)}

Platform specifications:
- x: Max 280 chars per post. Support threads (threadPosts array). Use 2-4 hashtags, emojis encouraged.
- linkedin: Max 1300 chars. Professional tone. 3-5 hashtags. No emojis in professional mode.
- instagram: Carousel descriptions. 20-30 hashtags. Heavy emoji usage.
- youtube: Video title + description. SEO-focused with keywords.
- threads: Max 500 chars. Casual tone. Support threads (threadPosts array).
- bluesky: Max 300 chars. Conversational. Minimal hashtags (1-2).
- newsletter: HTML email body (emailHtml) with subject line (emailSubject). Include sections, CTA button.

Return JSON:
{
  "title": "${brief.title}",
  "description": "1-2 sentence campaign description",
  "duration": "Human-readable date range",
  "platforms": ${JSON.stringify(selectedPlatforms)},
  "phases": ${JSON.stringify(structure.phases.map(p => ({ name: p.name, label: p.label, days: p.days })))},
  "posts": [
    {
      "platform": "x",
      "postType": "thread",
      "stepNumber": 1,
      "content": "Main post content",
      "scheduledAt": "2026-03-20T09:00:00",
      "status": "draft",
      "hashtags": ["Tag1", "Tag2"],
      "phase": 1,
      "phaseLabel": "Phase Label",
      "threadPosts": ["Tweet 1", "Tweet 2"],
      "emailSubject": null,
      "emailHtml": null
    }
  ]
}

Rules:
- Generate posts matching the cadence in each phase (e.g. if cadence says x:3, generate 3 X posts for that phase)
- stepNumber increments globally across all posts
- For X/Threads "thread" postType, include threadPosts array
- For newsletter, include emailSubject + emailHtml with inline CSS
- scheduledAt dates should spread across the phase day ranges, during working hours (9am-5pm)
- Content must reference specific details from the brief key messages
- Respect each platform's character limits${phaseHints ? `\n\nPhase-specific instructions from the user:\n${phaseHints}` : ''}`;

	try {
		const result = await model.generateContent(prompt);
		const text = result.response.text();
		const generated = JSON.parse(text);

		const now = Date.now();
		const campaignId = `wiz-${now}`;
		const campaign: Campaign = {
			id: campaignId,
			title: generated.title || brief.title,
			description: generated.description || "",
			duration: generated.duration || `${brief.startDate} to ${brief.endDate}`,
			platforms: generated.platforms || selectedPlatforms,
			phases: generated.phases || structure.phases.map(p => ({ name: p.name, label: p.label, days: p.days })),
			posts: (generated.posts || []).map((p: any, i: number) => ({
				id: `${campaignId}-post-${i}`,
				platform: p.platform || "x",
				postType: p.postType || "text",
				stepNumber: p.stepNumber || i + 1,
				content: p.content || "",
				scheduledAt: p.scheduledAt || new Date().toISOString(),
				status: p.status || "draft",
				hashtags: p.hashtags || [],
				phase: p.phase || 1,
				phaseLabel: p.phaseLabel || "",
				...(p.threadPosts ? { threadPosts: p.threadPosts } : {}),
				...(p.emailSubject ? { emailSubject: p.emailSubject } : {}),
				...(p.emailHtml ? { emailHtml: p.emailHtml } : {}),
			})),
			createdAt: now,
			updatedAt: now,
		};

		_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} → content`, (d) => {
			const w = d.campaignWizards?.[id];
			if (!w) return;
			w.campaignDraft = campaign;
			w.step = 'content';
			w.updatedAt = Date.now();
		});

		const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
		return c.json(updated.campaignWizards[id]);
	} catch (e: any) {
		console.error("[rSocials] Wizard content error:", e.message);
		return c.json({ error: "Failed to generate content: " + (e.message || "unknown") }, 502);
	}
});

routes.post("/api/campaign/wizard/:id/regen-post", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

	const docId = socialsDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	const wizard = doc.campaignWizards?.[id];
	if (!wizard) return c.json({ error: "Wizard not found" }, 404);
	if (!wizard.campaignDraft) return c.json({ error: "No campaign draft to regenerate from" }, 400);

	const body = await c.req.json();
	const { postIndex, instructions } = body;
	if (postIndex === undefined || postIndex < 0 || postIndex >= wizard.campaignDraft.posts.length) {
		return c.json({ error: "Invalid postIndex" }, 400);
	}

	const oldPost = wizard.campaignDraft.posts[postIndex];
	const brief = wizard.extractedBrief;

	const { GoogleGenerativeAI } = await import("@google/generative-ai");
	const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
	const model = genAI.getGenerativeModel({
		model: "gemini-2.5-flash",
		generationConfig: { responseMimeType: "application/json" } as any,
	});

	const prompt = `Regenerate this social media post. Keep the same platform, phase, and scheduling but improve the content.

Current post:
${JSON.stringify(oldPost, null, 2)}

Campaign context:
- Title: ${brief?.title || wizard.campaignDraft.title}
- Key messages: ${brief?.keyMessages?.join('; ') || 'N/A'}
- Tone: ${brief?.tone || 'professional'}

${instructions ? `User instructions: ${instructions}` : ''}

Platform limits: x=280 chars, linkedin=1300, instagram=carousel, threads=500, bluesky=300, newsletter=HTML email.

Return the regenerated post as JSON with the same fields (platform, postType, stepNumber, content, scheduledAt, status, hashtags, phase, phaseLabel, threadPosts if applicable, emailSubject/emailHtml if newsletter).`;

	try {
		const result = await model.generateContent(prompt);
		const text = result.response.text();
		const newPost = JSON.parse(text);

		_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} regen post ${postIndex}`, (d) => {
			const w = d.campaignWizards?.[id];
			if (!w?.campaignDraft?.posts?.[postIndex]) return;
			const p = w.campaignDraft.posts[postIndex];
			p.content = newPost.content || p.content;
			p.hashtags = newPost.hashtags || p.hashtags;
			if (newPost.threadPosts) p.threadPosts = newPost.threadPosts;
			if (newPost.emailSubject) p.emailSubject = newPost.emailSubject;
			if (newPost.emailHtml) p.emailHtml = newPost.emailHtml;
			w.updatedAt = Date.now();
		});

		const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
		return c.json(updated.campaignWizards[id].campaignDraft!.posts[postIndex]);
	} catch (e: any) {
		console.error("[rSocials] Wizard regen error:", e.message);
		return c.json({ error: "Failed to regenerate post: " + (e.message || "unknown") }, 502);
	}
});

routes.post("/api/campaign/wizard/:id/commit", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");

	const docId = socialsDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	const wizard = doc.campaignWizards?.[id];
	if (!wizard) return c.json({ error: "Wizard not found" }, 404);
	if (!wizard.campaignDraft) return c.json({ error: "No campaign draft to commit" }, 400);

	const campaign = { ...wizard.campaignDraft } as Campaign;
	const now = Date.now();
	campaign.updatedAt = now;

	// 1. Save campaign to SocialsDoc.campaigns
	_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} commit campaign`, (d) => {
		if (!d.campaigns) d.campaigns = {} as any;
		(d.campaigns as any)[campaign.id] = campaign;
	});

	// 2. Create ThreadData entries for posts with threadPosts
	const threadIds: string[] = [];
	const threadInfos: { id: string; title: string }[] = [];
	for (const post of campaign.posts) {
		if (post.threadPosts && post.threadPosts.length > 0) {
			const threadId = `thread-${now}-${Math.random().toString(36).substring(2, 6)}`;
			const threadTitle = `${campaign.title} — ${post.phaseLabel} (${post.platform})`;
			const threadData: ThreadData = {
				id: threadId,
				name: post.phaseLabel || 'Campaign Thread',
				handle: '@campaign',
				title: threadTitle,
				tweets: post.threadPosts,
				imageUrl: null,
				tweetImages: null,
				createdAt: now,
				updatedAt: now,
			};
			_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} create thread ${threadId}`, (d) => {
				if (!d.threads) d.threads = {} as any;
				(d.threads as any)[threadId] = threadData;
			});
			// Link the campaign post back to the created thread
			_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} link post ${post.id} → thread ${threadId}`, (d) => {
				const cp = d.campaigns?.[campaign.id]?.posts?.find((p: any) => p.id === post.id);
				if (cp) (cp as any).threadId = threadId;
			});
			threadIds.push(threadId);
			threadInfos.push({ id: threadId, title: threadTitle });
		}
	}

	// 3. Draft newsletters in Listmonk (skip gracefully if not configured)
	const newsletterResults: { subject: string; listmonkId?: number; error?: string }[] = [];
	const listmonkConfig = await getListmonkConfig(dataSpace);
	for (const post of campaign.posts) {
		if (post.platform === 'newsletter' && post.emailSubject && post.emailHtml) {
			if (!listmonkConfig) {
				newsletterResults.push({ subject: post.emailSubject, error: 'Listmonk not configured' });
				continue;
			}
			try {
				const res = await listmonkFetch(listmonkConfig, '/api/campaigns', {
					method: 'POST',
					body: JSON.stringify({
						name: post.emailSubject,
						subject: post.emailSubject,
						body: post.emailHtml,
						content_type: 'html',
						type: 'regular',
						status: 'draft',
						lists: [1],
					}),
				});
				const data = await res.json().catch(() => ({}));
				newsletterResults.push({ subject: post.emailSubject, listmonkId: data?.data?.id });
			} catch (e: any) {
				newsletterResults.push({ subject: post.emailSubject, error: e.message });
			}
		}
	}

	// 4. Build a CampaignWorkflow DAG
	const wfId = `wf-${campaign.id}`;
	const wfNodes: CampaignWorkflowNode[] = [];
	const wfEdges: CampaignWorkflowEdge[] = [];

	// Start trigger node
	const startNodeId = `node-start`;
	wfNodes.push({
		id: startNodeId,
		type: 'campaign-start',
		label: `Start: ${campaign.title}`,
		position: { x: 100, y: 300 },
		config: { description: `Launch ${campaign.title}` },
	});

	let prevNodeId = startNodeId;
	let xPos = 350;
	const startDate = new Date(wizard.extractedBrief?.startDate || Date.now());

	for (let pi = 0; pi < campaign.phases.length; pi++) {
		const phase = campaign.phases[pi];
		const phasePosts = campaign.posts.filter(p => p.phase === pi + 1);

		// Wait node for phase offset
		const waitId = `node-wait-phase-${pi}`;
		wfNodes.push({
			id: waitId,
			type: 'wait-duration',
			label: `Wait: ${phase.label}`,
			position: { x: xPos, y: 300 },
			config: { amount: pi * 7, unit: 'days' },
		});
		wfEdges.push({
			id: `edge-${prevNodeId}-${waitId}`,
			fromNode: prevNodeId, fromPort: prevNodeId === startNodeId ? 'trigger' : 'done',
			toNode: waitId, toPort: 'trigger',
		});
		xPos += 250;

		// Action nodes for each post in phase
		let yPos = 100;
		for (const post of phasePosts) {
			const actionId = `node-post-${post.id}`;
			const nodeType: CampaignWorkflowNode['type'] = post.threadPosts?.length
				? 'publish-thread' : post.platform === 'newsletter'
				? 'send-newsletter' : 'post-to-platform';

			wfNodes.push({
				id: actionId,
				type: nodeType,
				label: `${post.platform}: ${post.phaseLabel}`,
				position: { x: xPos, y: yPos },
				config: {
					platform: post.platform,
					content: post.content,
					hashtags: post.hashtags.join(' '),
					...(post.threadPosts ? { threadContent: post.threadPosts.join('\n---\n') } : {}),
					...(post.emailSubject ? { subject: post.emailSubject } : {}),
					...(post.emailHtml ? { bodyTemplate: post.emailHtml } : {}),
				},
			});
			wfEdges.push({
				id: `edge-${waitId}-${actionId}`,
				fromNode: waitId, fromPort: 'done',
				toNode: actionId, toPort: 'trigger',
			});
			yPos += 120;
		}
		prevNodeId = waitId;
		xPos += 250;
	}

	const workflow: CampaignWorkflow = {
		id: wfId,
		name: `${campaign.title} Workflow`,
		enabled: false,
		nodes: wfNodes,
		edges: wfEdges,
		lastRunAt: null,
		lastRunStatus: null,
		runCount: 0,
		createdAt: now,
		updatedAt: now,
	};

	// 5. Save workflow to SocialsDoc.campaignWorkflows
	_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} create workflow`, (d) => {
		if (!d.campaignWorkflows) d.campaignWorkflows = {} as any;
		(d.campaignWorkflows as any)[wfId] = workflow;
	});

	// 6. Build a CampaignFlow from the same data so the planner can open it
	const flowId = `flow-${now}-${Math.random().toString(36).slice(2, 6)}`;
	const flowNodes: CampaignPlannerNode[] = [];
	const flowEdges: CampaignEdge[] = [];
	const brief = wizard.extractedBrief;

	// Input column (x=50): goal, messages, tone
	const goalNodeId = `goal-${now}`;
	flowNodes.push({
		id: goalNodeId, type: 'goal',
		position: { x: 50, y: 50 },
		data: {
			label: campaign.title || 'Campaign Goal',
			objective: campaign.description || brief?.audience || '',
			startDate: brief?.startDate || '',
			endDate: brief?.endDate || '',
		} as GoalNodeData,
	});

	const msgIds: string[] = [];
	(brief?.keyMessages || []).forEach((msg, i) => {
		const mid = `message-${now}-${i}`;
		msgIds.push(mid);
		flowNodes.push({
			id: mid, type: 'message',
			position: { x: 50, y: 220 + i * 130 },
			data: {
				label: msg.substring(0, 40),
				text: msg,
				priority: i === 0 ? 'high' : 'medium',
			} as MessageNodeData,
		});
	});

	const toneNodeId = `tone-${now}`;
	flowNodes.push({
		id: toneNodeId, type: 'tone',
		position: { x: 50, y: 220 + (brief?.keyMessages?.length || 0) * 130 },
		data: {
			label: `${brief?.tone || 'Professional'} tone`,
			tone: brief?.tone || 'professional',
			style: brief?.style || 'awareness',
			audience: brief?.audience || '',
			sizeEstimate: '',
		} as ToneNodeData,
	});

	// Phase column (x=350)
	const phaseIds: string[] = [];
	campaign.phases.forEach((phase, pi) => {
		const pid = `phase-${now}-${pi}`;
		phaseIds.push(pid);
		const phaseColors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
		flowNodes.push({
			id: pid, type: 'phase',
			position: { x: 350, y: 50 + pi * 320 },
			data: {
				label: phase.label || `Phase ${pi + 1}`,
				dateRange: phase.days || '',
				color: phaseColors[pi % phaseColors.length],
				progress: 0,
				childNodeIds: [],
				size: { w: 400, h: 280 },
			} as PhaseNodeData,
		});
		flowEdges.push({
			id: `e-goal-phase-${pi}`,
			from: goalNodeId, to: pid, type: 'feeds',
		});
	});

	// Post + platform columns
	const platformMap = new Map<string, string>();
	campaign.posts.forEach((post, pi) => {
		const postNodeId = `post-${now}-${pi}`;
		const phaseIdx = Math.max(0, (post.phase || 1) - 1);
		const phaseY = 50 + phaseIdx * 320;
		flowNodes.push({
			id: postNodeId, type: 'post',
			position: { x: 400 + (pi % 3) * 260, y: phaseY + 60 + Math.floor(pi / 3) * 140 },
			data: {
				label: (post.content || '').split('\n')[0].substring(0, 40) || post.platform,
				platform: post.platform || 'x',
				postType: post.postType || 'text',
				content: post.content || '',
				scheduledAt: post.scheduledAt || '',
				status: (post.status as any) || 'draft',
				hashtags: post.hashtags || [],
			} as PostNodeData,
		});
		if (msgIds.length > 0) {
			flowEdges.push({
				id: `e-msg-post-${pi}`,
				from: msgIds[pi % msgIds.length], to: postNodeId, type: 'feeds',
			});
		}
		flowEdges.push({
			id: `e-tone-post-${pi}`,
			from: toneNodeId, to: postNodeId, type: 'feeds',
		});

		const plat = post.platform || 'x';
		if (!platformMap.has(plat)) {
			const platId = `platform-${now}-${plat}`;
			platformMap.set(plat, platId);
			const platIdx = platformMap.size - 1;
			flowNodes.push({
				id: platId, type: 'platform',
				position: { x: 950, y: 50 + platIdx * 120 },
				data: {
					label: plat.charAt(0).toUpperCase() + plat.slice(1),
					platform: plat,
					handle: '',
				} as PlatformNodeData,
			});
		}
		flowEdges.push({
			id: `e-post-plat-${pi}`,
			from: postNodeId, to: platformMap.get(plat)!, type: 'publish',
		});
	});

	const flow: CampaignFlow = {
		id: flowId,
		name: campaign.title,
		nodes: flowNodes,
		edges: flowEdges,
		createdAt: now,
		updatedAt: now,
		createdBy: null,
	};

	_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} create flow`, (d) => {
		if (!d.campaignFlows) d.campaignFlows = {} as any;
		(d.campaignFlows as any)[flowId] = flow;
		d.activeFlowId = flowId;
	});

	// 7. Mark wizard as committed
	_syncServer!.changeDoc<SocialsDoc>(docId, `wizard ${id} → committed`, (d) => {
		const w = d.campaignWizards?.[id];
		if (!w) return;
		w.step = 'committed';
		w.committedCampaignId = campaign.id;
		w.updatedAt = Date.now();
	});

	return c.json({
		ok: true,
		campaignId: campaign.id,
		flowId,
		threadIds,
		threads: threadInfos,
		workflowId: wfId,
		newsletters: newsletterResults,
	});
});

routes.delete("/api/campaign/wizard/:id", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");

	const docId = socialsDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	if (!doc.campaignWizards?.[id]) return c.json({ error: "Wizard not found" }, 404);

	_syncServer!.changeDoc<SocialsDoc>(docId, `abandon wizard ${id}`, (d) => {
		if (d.campaignWizards?.[id]) {
			d.campaignWizards[id].step = 'abandoned';
			d.campaignWizards[id].updatedAt = Date.now();
		}
	});

	return c.json({ ok: true });
});

// ── Campaign Flow (graph-based) API ──

routes.post("/api/campaign/flow/from-brief", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

	const body = await c.req.json();
	const { rawBrief, platforms } = body;
	if (!rawBrief || typeof rawBrief !== "string" || rawBrief.trim().length < 10) {
		return c.json({ error: "rawBrief required (min 10 characters)" }, 400);
	}
	const selectedPlatforms: string[] = Array.isArray(platforms) && platforms.length > 0
		? platforms
		: ["x", "linkedin", "instagram", "threads", "bluesky"];

	const { GoogleGenerativeAI } = await import("@google/generative-ai");
	const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
	const model = genAI.getGenerativeModel({
		model: "gemini-2.5-flash",
		generationConfig: { responseMimeType: "application/json" } as any,
	});

	const prompt = `You are a social media campaign strategist. Analyze this campaign brief and return structured campaign data.

Brief:
"""
${rawBrief.trim()}
"""

Platforms to target: ${selectedPlatforms.join(', ')}

Return JSON:
{
  "goal": { "title": "Campaign title", "objective": "What we want to achieve", "startDate": "YYYY-MM-DD", "endDate": "YYYY-MM-DD" },
  "messages": [
    { "text": "Key message 1", "priority": "high" },
    { "text": "Key message 2", "priority": "medium" }
  ],
  "tone": { "tone": "professional | casual | urgent | inspirational", "style": "event-promo | product-launch | awareness | community", "audience": "Target audience description" },
  "phases": [
    { "name": "phase-slug", "label": "Phase Label", "days": "Day 1-3", "color": "#hex" }
  ],
  "posts": [
    { "platform": "x", "postType": "text", "content": "Post content", "scheduledAt": "2026-04-20T09:00:00", "hashtags": ["tag1"], "phase": 0, "phaseLabel": "Phase Label" }
  ]
}

Rules:
- 2-4 key messages, 3-5 phases
- Generate posts matching cadence for each phase × platform
- Use today (${new Date().toISOString().split('T')[0]}) as reference
- Respect platform char limits (x=280, linkedin=1300, threads=500, bluesky=300)
- Phase colors: use distinct hex colors (#6366f1, #10b981, #f59e0b, #ef4444, #8b5cf6)`;

	try {
		const result = await model.generateContent(prompt);
		const text = result.response.text();
		const parsed = JSON.parse(text);

		const now = Date.now();
		const flowId = `flow-${now}-${Math.random().toString(36).slice(2, 8)}`;
		const nodes: CampaignPlannerNode[] = [];
		const edges: CampaignEdge[] = [];

		// Col 1: Input nodes (x=50)
		const goalId = `goal-${now}-g`;
		nodes.push({
			id: goalId,
			type: 'goal',
			position: { x: 50, y: 50 },
			data: {
				label: parsed.goal?.title || 'Campaign Goal',
				objective: parsed.goal?.objective || '',
				startDate: parsed.goal?.startDate || '',
				endDate: parsed.goal?.endDate || '',
			} as GoalNodeData,
		});

		const messageIds: string[] = [];
		(parsed.messages || []).forEach((msg: any, i: number) => {
			const mid = `message-${now}-${i}`;
			messageIds.push(mid);
			nodes.push({
				id: mid,
				type: 'message',
				position: { x: 50, y: 220 + i * 130 },
				data: {
					label: (msg.text || '').substring(0, 40),
					text: msg.text || '',
					priority: msg.priority || 'medium',
				} as MessageNodeData,
			});
		});

		const toneId = `tone-${now}-t`;
		nodes.push({
			id: toneId,
			type: 'tone',
			position: { x: 50, y: 220 + messageIds.length * 130 },
			data: {
				label: `${parsed.tone?.tone || 'Professional'} tone`,
				tone: parsed.tone?.tone || 'professional',
				style: parsed.tone?.style || 'awareness',
				audience: parsed.tone?.audience || '',
				sizeEstimate: '',
			} as ToneNodeData,
		});

		// Col 2: Phase containers (x=350)
		const phaseIds: string[] = [];
		(parsed.phases || []).forEach((phase: any, i: number) => {
			const pid = `phase-${now}-${i}`;
			phaseIds.push(pid);
			nodes.push({
				id: pid,
				type: 'phase',
				position: { x: 350, y: 50 + i * 320 },
				data: {
					label: phase.label || `Phase ${i + 1}`,
					dateRange: phase.days || '',
					color: phase.color || '#6366f1',
					progress: 0,
					childNodeIds: [],
					size: { w: 400, h: 280 },
				} as PhaseNodeData,
			});

			// goal → phase (feeds)
			edges.push({
				id: `e-${now}-goal-phase-${i}`,
				from: goalId,
				to: pid,
				type: 'feeds',
			});
		});

		// Col 3: Post nodes (inside phases) + platform nodes (x=850)
		const platformNodeMap = new Map<string, string>();
		let postIdx = 0;
		(parsed.posts || []).forEach((post: any) => {
			const postId = `post-${now}-${postIdx++}`;
			const phaseIdx = typeof post.phase === 'number' ? post.phase : 0;
			const phaseY = 50 + phaseIdx * 320;
			const postY = phaseY + 60 + (postIdx % 5) * 140;

			nodes.push({
				id: postId,
				type: 'post',
				position: { x: 400 + (postIdx % 3) * 260, y: postY },
				data: {
					label: (post.content || '').split('\n')[0].substring(0, 40) || 'Post',
					platform: post.platform || 'x',
					postType: post.postType || 'text',
					content: post.content || '',
					scheduledAt: post.scheduledAt || '',
					status: 'draft',
					hashtags: post.hashtags || [],
				} as PostNodeData,
			});

			// message → post (feeds)
			if (messageIds.length > 0) {
				const msgIdx = postIdx % messageIds.length;
				edges.push({
					id: `e-${now}-msg-post-${postIdx}`,
					from: messageIds[msgIdx],
					to: postId,
					type: 'feeds',
				});
			}

			// tone → post (feeds)
			edges.push({
				id: `e-${now}-tone-post-${postIdx}`,
				from: toneId,
				to: postId,
				type: 'feeds',
			});

			// Ensure platform node exists + post → platform (publish)
			const plat = post.platform || 'x';
			if (!platformNodeMap.has(plat)) {
				const platId = `platform-${now}-${plat}`;
				platformNodeMap.set(plat, platId);
				const platIdx = platformNodeMap.size - 1;
				nodes.push({
					id: platId,
					type: 'platform',
					position: { x: 950, y: 50 + platIdx * 120 },
					data: {
						label: plat.charAt(0).toUpperCase() + plat.slice(1),
						platform: plat,
						handle: '',
					} as PlatformNodeData,
				});
			}
			edges.push({
				id: `e-${now}-post-plat-${postIdx}`,
				from: postId,
				to: platformNodeMap.get(plat)!,
				type: 'publish',
			});
		});

		// Save flow to Automerge
		const flow: CampaignFlow = {
			id: flowId,
			name: parsed.goal?.title || 'Campaign Flow',
			nodes,
			edges,
			createdAt: now,
			updatedAt: now,
			createdBy: null,
		};

		const docId = socialsDocId(dataSpace);
		ensureDoc(dataSpace);
		_syncServer!.changeDoc<SocialsDoc>(docId, `create campaign flow ${flowId}`, (d) => {
			if (!d.campaignFlows) d.campaignFlows = {} as any;
			(d.campaignFlows as any)[flowId] = flow;
			d.activeFlowId = flowId;
		});

		return c.json(flow, 201);
	} catch (e: any) {
		console.error("[rSocials] Flow from-brief error:", e.message);
		return c.json({ error: "Failed to generate flow: " + (e.message || "unknown") }, 502);
	}
});

routes.post("/api/campaign/flow/regen-nodes", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

	const body = await c.req.json();
	const { flowId, nodeIds } = body;
	if (!flowId || !Array.isArray(nodeIds) || nodeIds.length === 0) {
		return c.json({ error: "flowId and nodeIds[] required" }, 400);
	}

	const docId = socialsDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	const flow = doc.campaignFlows?.[flowId];
	if (!flow) return c.json({ error: "Flow not found" }, 404);

	// Gather input context from upstream nodes via feeds edges
	const inputNodes = flow.nodes.filter(n => n.type === 'goal' || n.type === 'message' || n.type === 'tone');
	const inputContext = {
		goals: inputNodes.filter(n => n.type === 'goal').map(n => n.data),
		messages: inputNodes.filter(n => n.type === 'message').map(n => n.data),
		tones: inputNodes.filter(n => n.type === 'tone').map(n => n.data),
	};

	const targetNodes = flow.nodes.filter(n => nodeIds.includes(n.id));
	if (targetNodes.length === 0) return c.json({ error: "No matching nodes found" }, 404);

	const { GoogleGenerativeAI } = await import("@google/generative-ai");
	const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
	const model = genAI.getGenerativeModel({
		model: "gemini-2.5-flash",
		generationConfig: { responseMimeType: "application/json" } as any,
	});

	const prompt = `Regenerate content for these social media posts based on updated campaign inputs.

Campaign inputs:
${JSON.stringify(inputContext, null, 2)}

Posts to regenerate (keep platform, postType, scheduledAt — only update content and hashtags):
${JSON.stringify(targetNodes.map(n => ({ id: n.id, type: n.type, data: n.data })), null, 2)}

Return JSON array with same structure, updated content:
[{ "id": "node-id", "content": "new content", "hashtags": ["tag"], "label": "short label" }]

Rules:
- Respect platform char limits (x=280, linkedin=1300, threads=500, bluesky=300)
- Incorporate the campaign goal, key messages, and tone
- Keep scheduling and platform unchanged`;

	try {
		const result = await model.generateContent(prompt);
		const text = result.response.text();
		const updates = JSON.parse(text);

		_syncServer!.changeDoc<SocialsDoc>(docId, `regen stale nodes in ${flowId}`, (d) => {
			const f = d.campaignFlows?.[flowId];
			if (!f) return;
			for (const upd of updates) {
				const node = f.nodes.find((n: any) => n.id === upd.id);
				if (!node) continue;
				if (node.type === 'post') {
					const pd = node.data as any;
					if (upd.content) pd.content = upd.content;
					if (upd.hashtags) pd.hashtags = upd.hashtags;
					if (upd.label) pd.label = upd.label;
				}
				(node as any).stale = false;
				(node as any).staleReason = '';
			}
			f.updatedAt = Date.now();
		});

		const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
		return c.json({ flow: updated.campaignFlows[flowId] });
	} catch (e: any) {
		console.error("[rSocials] Flow regen error:", e.message);
		return c.json({ error: "Failed to regenerate: " + (e.message || "unknown") }, 502);
	}
});

routes.post("/api/campaign/flow/ai-fill-node", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const GEMINI_API_KEY = process.env.GEMINI_API_KEY || "";
	if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);

	const body = await c.req.json();
	const { flowId, nodeId } = body;
	if (!flowId || !nodeId) return c.json({ error: "flowId and nodeId required" }, 400);

	const docId = socialsDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	const flow = doc.campaignFlows?.[flowId];
	if (!flow) return c.json({ error: "Flow not found" }, 404);

	const node = flow.nodes.find(n => n.id === nodeId);
	if (!node) return c.json({ error: "Node not found" }, 404);

	// Read upstream input nodes connected via feeds edges
	const upstreamIds = flow.edges
		.filter(e => e.to === nodeId && e.type === 'feeds')
		.map(e => e.from);
	const upstreamNodes = flow.nodes.filter(n => upstreamIds.includes(n.id));

	const inputContext = {
		goals: upstreamNodes.filter(n => n.type === 'goal').map(n => n.data),
		messages: upstreamNodes.filter(n => n.type === 'message').map(n => n.data),
		tones: upstreamNodes.filter(n => n.type === 'tone').map(n => n.data),
	};

	const { GoogleGenerativeAI } = await import("@google/generative-ai");
	const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
	const model = genAI.getGenerativeModel({
		model: "gemini-2.5-flash",
		generationConfig: { responseMimeType: "application/json" } as any,
	});

	const prompt = `Generate content for this social media post based on campaign context.

Campaign inputs:
${JSON.stringify(inputContext, null, 2)}

Target node to fill:
${JSON.stringify({ id: node.id, type: node.type, data: node.data }, null, 2)}

Return JSON: { "content": "post content", "hashtags": ["tag"], "label": "short label" }

Platform limits: x=280, linkedin=1300, threads=500, bluesky=300. Incorporate goal, messages, and tone.`;

	try {
		const result = await model.generateContent(prompt);
		const text = result.response.text();
		const generated = JSON.parse(text);

		_syncServer!.changeDoc<SocialsDoc>(docId, `ai fill node ${nodeId}`, (d) => {
			const f = d.campaignFlows?.[flowId];
			if (!f) return;
			const n = f.nodes.find((nd: any) => nd.id === nodeId);
			if (!n || n.type !== 'post') return;
			const pd = n.data as any;
			if (generated.content) pd.content = generated.content;
			if (generated.hashtags) pd.hashtags = generated.hashtags;
			if (generated.label) pd.label = generated.label;
			(n as any).stale = false;
			(n as any).staleReason = '';
			f.updatedAt = Date.now();
		});

		const updated = _syncServer!.getDoc<SocialsDoc>(docId)!;
		const updatedNode = updated.campaignFlows[flowId]?.nodes.find(n => n.id === nodeId);
		return c.json({ node: updatedNode });
	} catch (e: any) {
		console.error("[rSocials] AI fill error:", e.message);
		return c.json({ error: "Failed to generate: " + (e.message || "unknown") }, 502);
	}
});

// ── Page routes (inject web components) ──

routes.get("/campaign-flow", (c) => {
	const space = c.req.param("space") || "demo";
	const flowId = c.req.query("id") || "";
	const idAttr = flowId ? ` flow-id="${escapeHtml(flowId)}"` : "";
	return c.html(renderShell({
		title: `Campaign Flow — rSocials | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-campaign-planner space="${escapeHtml(space)}"${idAttr}></folk-campaign-planner>`,
		styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-planner.css">`,
		scripts: `<script type="module" src="/modules/rsocials/folk-campaign-planner.js"></script>`,
	}));
});

routes.get("/campaign-wizard/:id", (c) => {
	const space = c.req.param("space") || "demo";
	const wizardId = c.req.param("id");
	return c.html(renderShell({
		title: `Campaign Wizard — rSocials | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-campaign-wizard space="${escapeHtml(space)}" wizard-id="${escapeHtml(wizardId)}"></folk-campaign-wizard>`,
		styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-wizard.css">`,
		scripts: `<script type="module" src="/modules/rsocials/folk-campaign-wizard.js"></script>`,
	}));
});

routes.get("/campaign-wizard", (c) => {
	const space = c.req.param("space") || "demo";
	return c.html(renderShell({
		title: `Campaign Wizard — rSocials | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-campaign-wizard space="${escapeHtml(space)}"></folk-campaign-wizard>`,
		styles: `<link rel="stylesheet" href="/modules/rsocials/campaign-wizard.css">`,
		scripts: `<script type="module" src="/modules/rsocials/folk-campaign-wizard.js"></script>`,
	}));
});

routes.get("/campaign", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	return c.html(renderShell({
		title: `Campaign — rSocials | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-campaign-manager space="${escapeHtml(space)}"></folk-campaign-manager>`,
		styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
		scripts: `<script type="module" src="/modules/rsocials/folk-campaign-manager.js"></script>`,
	}));
});

routes.get("/thread/:id", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404);

	const thread = getThreadFromDoc(dataSpace, id);
	if (!thread) return c.text("Thread not found", 404);

	// OG tags for social crawlers (SSR)
	const desc = escapeHtml((thread.tweets[0] || "").substring(0, 200));
	const titleText = escapeHtml(`Thread by ${thread.handle}`);
	const origin = "https://rspace.online";

	let ogHead = `
		<meta property="og:title" content="${titleText}">
		<meta property="og:description" content="${desc}">
		<meta property="og:type" content="article">
		<meta name="twitter:card" content="summary_large_image">
		<meta name="twitter:title" content="${titleText}">
		<meta name="twitter:description" content="${desc}">`;
	if (thread.imageUrl) {
		ogHead += `
		<meta property="og:image" content="${origin}${thread.imageUrl}">
		<meta name="twitter:image" content="${origin}${thread.imageUrl}">`;
	}

	// Hydrate thread data for the component
	const dataScript = `<script>window.__THREAD_DATA__ = ${JSON.stringify(thread).replace(/</g, "\\u003c")};</script>`;

	return c.html(renderShell({
		title: `${thread.title || "Thread"} — rSocials | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `${dataScript}<folk-thread-builder space="${escapeHtml(space)}" thread-id="${escapeHtml(id)}" mode="readonly"></folk-thread-builder>`,
		styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
		scripts: `<script type="module" src="/modules/rsocials/folk-thread-builder.js"></script>`,
		head: ogHead,
	}));
});

routes.get("/thread-editor/:id/edit", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");
	if (!id || id.includes("..") || id.includes("/")) return c.text("Not found", 404);

	const thread = getThreadFromDoc(dataSpace, id);
	if (!thread) return c.text("Thread not found", 404);

	const dataScript = `<script>window.__THREAD_DATA__ = ${JSON.stringify(thread).replace(/</g, "\\u003c")};</script>`;

	return c.html(renderShell({
		title: `Edit: ${thread.title || "Thread"} — rSocials | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `${dataScript}<folk-thread-builder space="${escapeHtml(space)}" thread-id="${escapeHtml(id)}" mode="edit"></folk-thread-builder>`,
		styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
		scripts: `<script type="module" src="/modules/rsocials/folk-thread-builder.js"></script>`,
	}));
});

routes.get("/thread-editor", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	return c.html(renderShell({
		title: `Thread Editor — rSocials | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-thread-builder space="${escapeHtml(space)}" mode="new"></folk-thread-builder>`,
		styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
		scripts: `<script type="module" src="/modules/rsocials/folk-thread-builder.js"></script>`,
	}));
});

routes.get("/threads", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	return c.html(renderShell({
		title: `Posts & Threads — rSocials | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-thread-gallery space="${escapeHtml(space)}"></folk-thread-gallery>`,
		styles: `<link rel="stylesheet" href="/modules/rsocials/socials.css">`,
		scripts: `<script type="module" src="/modules/rsocials/folk-thread-gallery.js"></script>`,
	}));
});

routes.get("/campaigns", (c) => {
	const space = c.req.param("space") || "demo";
	return c.html(renderShell({
		title: `Campaigns — rSocials | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-campaigns-dashboard space="${escapeHtml(space)}"></folk-campaigns-dashboard>`,
		styles: ``,
		scripts: `<script type="module" src="/modules/rsocials/folk-campaigns-dashboard.js"></script>`,
	}));
});

// ── Demo feed rendering (server-rendered, no web component needed) ──

function renderDemoFeedHTML(): string {
	const cards = DEMO_FEED.map(
		(post) => `
		<article class="rsocials-item">
			<div class="rsocials-item-header">
				<div class="rsocials-avatar" style="background:${post.color}">${post.initial}</div>
				<div class="rsocials-meta">
					<strong>${post.username}</strong>
					<time>${post.timeAgo}</time>
				</div>
			</div>
			<p class="rsocials-item-content">${post.content}</p>
			<div class="rsocials-item-actions">
				<span class="rsocials-action"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg> ${post.likes}</span>
				<span class="rsocials-action"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> ${post.replies}</span>
			</div>
		</article>`,
	).join("\n");

	return `
		<div class="rsocials-app rsocials-demo">
			<div class="rsocials-header">
				<div style="display:flex;align-items:center;justify-content:space-between;gap:1rem">
					<h2>Social Feed <span class="rsocials-demo-badge">DEMO</span></h2>
					<a href="?view=app" class="rapp-nav__btn--app-toggle">Open Full App</a>
				</div>
				<p class="rsocials-subtitle">A preview of your community's social timeline</p>
			</div>
			<div class="rsocials-feed">${cards}</div>
			<p class="rsocials-demo-notice">This is demo data. Connect ActivityPub or RSS feeds in your own space.</p>
		</div>`;
}

// ── Path-based sub-routes ──

// ── Postiz scheduler — embedded via iframe ──
// Postiz runs at demo.rsocials.online (Traefik priority 130 > rSpace's 120).
// The /scheduler route renders a full-page iframe shell.
const POSTIZ_URL = process.env.POSTIZ_URL || "https://demo.rsocials.online";


routes.get("/scheduler", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	return c.html(renderExternalAppShell({
		title: `Post Scheduler — rSocials | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		appName: "Postiz",
		appUrl: POSTIZ_URL,
	}));
});

routes.get("/newsletter", async (c) => {
	const space = c.req.param("space") || "demo";

	// Resolve caller role for UI gating (viewer fallback for unauthenticated)
	let role: SpaceRoleString = 'viewer';
	const token = extractToken(c.req.raw.headers);
	if (token) {
		try {
			const claims = await verifyToken(token);
			const result = await resolveCallerRole(space, claims);
			if (result) role = result.role;
		} catch { /* keep viewer default */ }
	}

	return c.html(renderShell({
		title: `Newsletter — rSocials | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-newsletter-manager space="${escapeHtml(space)}" role="${escapeHtml(role)}"></folk-newsletter-manager>`,
		styles: `<link rel="stylesheet" href="/modules/rsocials/newsletter.css">`,
		scripts: `<script type="module" src="/modules/rsocials/folk-newsletter-manager.js"></script>`,
	}));
});

// Legacy redirect
routes.get("/newsletter-list", (c) => {
	const space = c.req.param("space") || "demo";
	const host = c.req.header("host")?.split(":")[0] || "";
	const isSubdomain = (host.endsWith(".rspace.online") && host !== "rspace.online" && !host.startsWith("www.")) || host.endsWith(".rsocials.online");
	const base = isSubdomain ? "/rsocials" : `/${space}/rsocials`;
	return c.redirect(`${base}/newsletter`, 301);
});

routes.get("/feed", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const isDemo = space === "demo";
	const body = isDemo ? renderDemoFeedHTML() : renderLanding();
	const styles = isDemo
		? `<link rel="stylesheet" href="/modules/rsocials/socials.css">`
		: `<style>${RICH_LANDING_CSS}</style>`;
	return c.html(renderShell({
		title: `${space} — Socials Feed | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body,
		styles,
	}));
});

routes.get("/landing", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	return c.html(renderShell({
		title: `${space} — rSocials | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: renderLanding(),
		styles: `<style>${RICH_LANDING_CSS}</style>`,
	}));
});

// ── Default: rSocials hub with navigation ──

routes.get("/", (c) => {
	const space = c.req.param("space") || "demo";
	const host = c.req.header("host")?.split(":")[0] || "";
	const isSubdomain = (host.endsWith(".rspace.online") && host !== "rspace.online" && !host.startsWith("www.")) || host.endsWith(".rsocials.online");
	const base = isSubdomain ? "/rsocials" : `/${escapeHtml(space)}/rsocials`;
	return c.html(renderShell({
		title: `rSocials — ${space} | rSpace`,
		moduleId: "rsocials",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		styles: `<style>
.rs-hub{max-width:720px;margin:3rem auto;padding:0 1.5rem}
.rs-hub h1{font-size:1.8rem;margin-bottom:.5rem}
.rs-hub p{color:var(--rs-text-secondary,#aaa);margin-bottom:2rem}
.rs-nav{display:flex;flex-direction:column;gap:1rem}
.rs-nav a{display:flex;align-items:center;gap:1rem;padding:1.25rem 1.5rem;border-radius:12px;background:var(--rs-surface,#1e1e2e);border:1px solid var(--rs-border,#333);text-decoration:none;color:inherit;transition:border-color .15s,background .15s}
.rs-nav a:hover{border-color:var(--rs-accent,#14b8a6);background:var(--rs-surface-hover,#252538)}
.rs-nav .nav-icon{font-size:2rem;flex-shrink:0}
.rs-nav .nav-body h3{margin:0 0 .25rem;font-size:1.1rem}
.rs-nav .nav-body p{margin:0;font-size:.85rem;color:var(--rs-text-secondary,#aaa)}
</style>`,
		body: `<div class="rs-hub">
  <h1>rSocials</h1>
  <p>Social media tools for your community</p>
  <nav class="rs-nav">
    <a href="${base}/campaigns">
      <span class="nav-icon">📢</span>
      <div class="nav-body">
        <h3>Campaigns</h3>
        <p>Plan and manage multi-platform social media campaigns</p>
      </div>
    </a>
    <a href="${base}/threads">
      <span class="nav-icon">🧵</span>
      <div class="nav-body">
        <h3>Posts & Threads</h3>
        <p>Browse draft posts, compose threads, and schedule content with live preview</p>
      </div>
    </a>
    <a href="${base}/newsletter">
      <span class="nav-icon">📧</span>
      <div class="nav-body">
        <h3>Newsletter</h3>
        <p>Draft newsletters, manage subscribers, and send campaigns</p>
      </div>
    </a>
  </nav>
</div>`,
	}));
});

// ── Module export ──

export const socialsModule: RSpaceModule = {
	id: "rsocials",
	name: "rSocials",
	icon: "📢",
	description: "Federated social feed aggregator for communities",
	canvasShapes: ["folk-social-post", "folk-social-thread", "folk-social-campaign", "folk-social-newsletter"],
	canvasToolIds: ["create_social_post", "create_social_thread", "create_campaign_card", "create_newsletter_card"],
	scoping: { defaultScope: "space", userConfigurable: true },
	docSchemas: [{ pattern: "{space}:socials:data", description: "Threads and campaigns", init: socialsSchema.init }],
	routes,
	publicWrite: true,
	settingsSchema: [
		{ key: 'listmonkUrl', label: 'Listmonk URL', type: 'string', description: 'Base URL of your Listmonk instance (e.g. https://newsletter.example.com)' },
		{ key: 'listmonkUser', label: 'Listmonk Username', type: 'string', description: 'API username for Listmonk' },
		{ key: 'listmonkPassword', label: 'Listmonk Password', type: 'password', description: 'API password for Listmonk' },
		{ key: 'postizUrl', label: 'Postiz URL', type: 'string', description: 'Base URL of your Postiz instance (e.g. https://demo.rsocials.online)' },
		{ key: 'postizApiKey', label: 'Postiz API Key', type: 'password', description: 'API key from Postiz Settings > Developers' },
	],
	standaloneDomain: "rsocials.online",
	landingPage: renderLanding,
	seedTemplate: seedTemplateSocials,
	async onInit(ctx) {
		_syncServer = ctx.syncServer;
		// Run migration for any existing file-based threads
		try { await migrateFileThreadsToAutomerge("demo"); } catch { /* ignore */ }
	},
	externalApp: { url: POSTIZ_URL, name: "Postiz" },
	feeds: [
		{
			id: "social-feed",
			name: "Social Feed",
			kind: "data",
			description: "Community social timeline — posts, links, and activity from connected platforms",
		},
	],
	acceptsFeeds: ["data", "trust"],
	outputPaths: [
		{ path: "campaigns", name: "Campaigns", icon: "📢", description: "Social media campaigns" },
		{ path: "posts", name: "Posts", icon: "📱", description: "Social feed posts across platforms" },
	],
	subPageInfos: [
		{
			path: "campaigns",
			title: "Campaign Manager",
			icon: "📢",
			tagline: "rSocials Tool",
			description: "Plan and track multi-platform social media campaigns with scheduling, analytics, and team collaboration.",
			features: [
				{ icon: "📅", title: "Schedule Posts", text: "Queue posts across platforms with a visual calendar timeline." },
				{ icon: "📊", title: "Track Performance", text: "Monitor engagement metrics and campaign reach in one dashboard." },
				{ icon: "👥", title: "Team Workflow", text: "Draft, review, and approve posts collaboratively before publishing." },
			],
		},
		{
			path: "threads",
			title: "Posts & Threads",
			icon: "🧵",
			tagline: "rSocials Tool",
			description: "Browse draft posts from campaigns, compose tweet threads, and schedule content with a live preview. Save drafts, generate share images, and publish when ready.",
			features: [
				{ icon: "✍️", title: "Live Preview", text: "See your thread as tweet cards in real time as you type, with character counts and thread numbering." },
				{ icon: "📝", title: "Draft Posts", text: "View draft and scheduled posts from your campaigns alongside thread drafts." },
				{ icon: "💾", title: "Save & Edit Drafts", text: "Save thread drafts to your space, revisit and refine them before publishing." },
			],
		},
		{
			path: "newsletter",
			title: "Newsletter",
			icon: "📧",
			tagline: "rSocials Tool",
			description: "Draft newsletters collaboratively, manage subscriber lists, and send email campaigns. Works standalone or with Listmonk integration.",
			features: [
				{ icon: "✍️", title: "Draft Editor", text: "Compose newsletter drafts with HTML editor, live preview, and collaborative editing." },
				{ icon: "👥", title: "Subscriber Management", text: "Build and manage subscriber lists directly, or import from Listmonk." },
				{ icon: "📨", title: "Send Campaigns", text: "Send newsletters via Listmonk when configured, or manage drafts independently." },
			],
		},
	],
	onboardingActions: [
		{ label: "Create a Thread", icon: "🧵", description: "Start a discussion thread", type: 'create', href: '/{space}/rsocials' },
	],
};

// ── MI Data Export ──

export interface MICampaignItem {
	id: string;
	title: string;
	description: string;
	platforms: string[];
	postCount: number;
	updatedAt: number;
}

export function getRecentCampaignsForMI(space: string, limit = 5): MICampaignItem[] {
	if (!_syncServer) return [];
	const doc = _syncServer.getDoc<SocialsDoc>(socialsDocId(space));
	if (!doc?.campaigns) return [];

	return Object.values(doc.campaigns)
		.sort((a, b) => b.updatedAt - a.updatedAt)
		.slice(0, limit)
		.map(c => ({
			id: c.id,
			title: c.title,
			description: (c.description || "").slice(0, 200),
			platforms: c.platforms,
			postCount: c.posts?.length ?? 0,
			updatedAt: c.updatedAt,
		}));
}
