/**
 * Network module — community relationship graph viewer.
 *
 * Visualizes CRM data as interactive force-directed graphs.
 * Nodes: people, companies, opportunities. Edges: relationships.
 * Syncs from Twenty CRM via GraphQL API proxy.
 */

import { Hono } from "hono";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing";

// ── CDN scripts for Three.js + 3d-force-graph ──
// UMD build bundles Three.js + all transitive deps — no bare-specifier issues.
// We also provide an importmap for "three" so folk-graph-viewer can import("three") for custom meshes.
const GRAPH3D_HEAD = `<script src="https://unpkg.com/3d-force-graph@1.73.4/dist/3d-force-graph.min.js"></script>
<script type="importmap">
{
  "imports": {
    "three": "https://unpkg.com/three@0.169.0/build/three.module.js"
  }
}
</script>`;

const routes = new Hono();

const TWENTY_API_URL = process.env.TWENTY_API_URL || "https://crm.rspace.online";
const TWENTY_DEFAULT_TOKEN = (process.env.TWENTY_API_TOKEN && process.env.TWENTY_API_TOKEN !== "disabled") ? process.env.TWENTY_API_TOKEN : "";

// Build token map from env vars: TWENTY_TOKEN_COMMONS_HUB -> "commons-hub"
const twentyTokens = new Map<string, string>();
for (const [key, value] of Object.entries(process.env)) {
	if (key.startsWith("TWENTY_TOKEN_") && value) {
		const slug = key.replace("TWENTY_TOKEN_", "").toLowerCase().replace(/_/g, "-");
		twentyTokens.set(slug, value);
	}
}

function getTokenForSpace(space: string): string {
	return twentyTokens.get(space) || TWENTY_DEFAULT_TOKEN;
}

// ── GraphQL helper ──
async function twentyQuery(query: string, variables?: Record<string, unknown>, space?: string) {
	const token = space ? getTokenForSpace(space) : TWENTY_DEFAULT_TOKEN;
	if (!token) return null;
	const res = await fetch(`${TWENTY_API_URL}/graphql`, {
		method: "POST",
		headers: {
			"Content-Type": "application/json",
			Authorization: `Bearer ${token}`,
		},
		body: JSON.stringify({ query, variables }),
		signal: AbortSignal.timeout(10000),
	});
	if (!res.ok) return null;
	const json = await res.json() as { data?: unknown };
	return json.data ?? null;
}

// ── Per-space cache layer (60s TTL) ──
const graphCaches = new Map<string, { data: unknown; ts: number }>();
const CACHE_TTL = 60_000;

// ── API: Health ──
routes.get("/api/health", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const token = getTokenForSpace(dataSpace);
	return c.json({ ok: true, module: "network", space, twentyConfigured: !!token });
});

// ── API: Info ──
routes.get("/api/info", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const token = getTokenForSpace(dataSpace);
	return c.json({
		module: "network",
		description: "Community relationship graph visualization",
		entityTypes: ["person", "company", "opportunity"],
		features: ["force-directed layout", "CRM sync", "real-time collaboration"],
		space,
		twentyConfigured: !!token,
	});
});

// ── API: People ──
routes.get("/api/people", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const token = getTokenForSpace(dataSpace);
	const data = await twentyQuery(`{
		people(first: 200) {
			edges {
				node {
					id
					name { firstName lastName }
					emails { primaryEmail }
					phones { primaryPhoneNumber }
					city
					company { id name }
					createdAt
				}
			}
		}
	}`, undefined, dataSpace);
	if (!data) return c.json({ people: [], error: token ? "Twenty API error" : "Twenty not configured" });
	const people = ((data as any).people?.edges || []).map((e: any) => e.node);
	c.header("Cache-Control", "public, max-age=60");
	return c.json({ people });
});

// ── API: Companies ──
routes.get("/api/companies", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const token = getTokenForSpace(dataSpace);
	const data = await twentyQuery(`{
		companies(first: 200) {
			edges {
				node {
					id
					name
					domainName { primaryLinkUrl }
					employees
					address { addressCity addressCountry }
					createdAt
				}
			}
		}
	}`, undefined, dataSpace);
	if (!data) return c.json({ companies: [], error: token ? "Twenty API error" : "Twenty not configured" });
	const companies = ((data as any).companies?.edges || []).map((e: any) => e.node);
	c.header("Cache-Control", "public, max-age=60");
	return c.json({ companies });
});

// Helper: resolve the trust/identity space (always per-space, ignoring module's global scoping)
function getTrustSpace(c: any): string {
	return c.req.query("space") || c.req.param("space") || "demo";
}

const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000";

// ── API: Users — EncryptID user directory with trust metadata ──
routes.get("/api/users", async (c) => {
	const space = getTrustSpace(c);
	try {
		const res = await fetch(`${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(space)}`, {
			signal: AbortSignal.timeout(5000),
		});
		if (!res.ok) return c.json({ users: [], error: "User directory unavailable" });
		return c.json(await res.json());
	} catch {
		return c.json({ users: [], error: "EncryptID unreachable" });
	}
});

// ── API: Trust scores for graph visualization ──
routes.get("/api/trust", async (c) => {
	const space = getTrustSpace(c);
	const authority = c.req.query("authority") || "gov-ops";
	try {
		const res = await fetch(
			`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(space)}&authority=${encodeURIComponent(authority)}`,
			{ signal: AbortSignal.timeout(5000) },
		);
		if (!res.ok) return c.json({ scores: [], authority, error: "Trust scores unavailable" });
		return c.json(await res.json());
	} catch {
		return c.json({ scores: [], authority, error: "EncryptID unreachable" });
	}
});

// ── API: Delegations for graph edges ──
routes.get("/api/delegations", async (c) => {
	const space = getTrustSpace(c);
	const authority = c.req.query("authority");
	try {
		const url = new URL(`${ENCRYPTID_URL}/api/delegations/space`);
		url.searchParams.set("space", space);
		if (authority) url.searchParams.set("authority", authority);
		const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
		if (!res.ok) return c.json({ delegations: [], error: "Delegations unavailable" });
		return c.json(await res.json());
	} catch {
		return c.json({ delegations: [], error: "EncryptID unreachable" });
	}
});

// ── API: Endorse — proxy to EncryptID trust engine ──
routes.post("/api/endorse", async (c) => {
	const auth = c.req.header("Authorization");
	if (!auth) return c.json({ error: "Unauthorized" }, 401);
	try {
		const body = await c.req.json();
		const res = await fetch(`${ENCRYPTID_URL}/api/trust/endorse`, {
			method: "POST",
			headers: { "Content-Type": "application/json", "Authorization": auth },
			body: JSON.stringify(body),
			signal: AbortSignal.timeout(5000),
		});
		return c.json(await res.json(), res.status as any);
	} catch {
		return c.json({ error: "EncryptID unreachable" }, 502);
	}
});

// ── API: Graph — transform entities to node/edge format ──
routes.get("/api/graph", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const token = getTokenForSpace(dataSpace);

	// Check per-space cache (keyed by space + trust params)
	const includeTrust = c.req.query("trust") === "true";
	const authority = c.req.query("authority") || "gov-ops";
	const trustSpace = c.req.param("space") || "demo";
	const cacheKey = includeTrust ? `${dataSpace}:${trustSpace}:trust:${authority}` : `${dataSpace}:${trustSpace}`;
	const cached = graphCaches.get(cacheKey);
	if (cached && Date.now() - cached.ts < CACHE_TTL) {
		c.header("Cache-Control", "public, max-age=60");
		return c.json(cached.data);
	}

	try {
		// Start with CRM data if available, otherwise demo placeholders
		const nodes: Array<{ id: string; label: string; type: string; data: unknown }> = [];
		const edges: Array<{ source: string; target: string; type: string; weight?: number }> = [];
		const nodeIds = new Set<string>();
		let isDemoData = false;

		// Demo members: always show in "demo" space, or when no CRM token
		const useDemoMembers = !token || space === "demo";
		if (useDemoMembers) {
			isDemoData = true;

			// ── Demo: 150 members with delegation-based trust flows ──
			// Deterministic PRNG for reproducible delegation edges
			function mulberry32(seed: number) {
				return () => {
					seed |= 0; seed = seed + 0x6D2B79F5 | 0;
					let t = Math.imul(seed ^ seed >>> 15, 1 | seed);
					t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
					return ((t ^ t >>> 14) >>> 0) / 4294967296;
				};
			}

			// Members: [id, name, permissionLevel, govW, finW, devW]
			const members: Array<[string, string, string, number, number, number]> = [
				// ── Admins (m001-m015): high trust weights ──
				["m001", "Alice Chen",           "admin",  0.95, 0.50, 0.40],
				["m002", "Bob Martinez",         "admin",  0.45, 0.90, 0.35],
				["m003", "Carol Okafor",         "admin",  0.40, 0.35, 0.95],
				["m004", "David Kim",            "admin",  0.80, 0.55, 0.45],
				["m005", "Eve Nakamura",         "admin",  0.50, 0.40, 0.85],
				["m006", "Frank Osei",           "admin",  0.35, 0.85, 0.30],
				["m007", "Grace Liu",            "admin",  0.70, 0.30, 0.65],
				["m008", "Hassan Patel",         "admin",  0.30, 0.75, 0.40],
				["m009", "Ingrid Svensson",      "admin",  0.60, 0.45, 0.50],
				["m010", "Jorge Reyes",          "admin",  0.40, 0.60, 0.55],
				["m011", "Kaia Tanaka",          "admin",  0.55, 0.35, 0.80],
				["m012", "Leo Adeyemi",          "admin",  0.45, 0.50, 0.70],
				["m013", "Anika Bergström",      "admin",  0.75, 0.40, 0.35],
				["m014", "Rafael Oliveira",      "admin",  0.35, 0.80, 0.45],
				["m015", "Chen Wei",             "admin",  0.40, 0.35, 0.75],
				// ── Members (m016-m050): medium weights ──
				["m016", "Fatima Al-Hassan",     "member", 0.50, 0.20, 0.15],
				["m017", "Marcus Johnson",       "member", 0.15, 0.55, 0.20],
				["m018", "Yuna Park",            "member", 0.20, 0.15, 0.55],
				["m019", "Dmitri Volkov",        "member", 0.45, 0.25, 0.20],
				["m020", "Amara Diallo",         "member", 0.15, 0.50, 0.25],
				["m021", "Liam O'Connor",        "member", 0.20, 0.20, 0.50],
				["m022", "Zara Hussain",         "member", 0.45, 0.15, 0.20],
				["m023", "Tomás Herrera",        "member", 0.20, 0.50, 0.15],
				["m024", "Sakura Ito",           "member", 0.15, 0.15, 0.50],
				["m025", "Maya Johansson",       "member", 0.30, 0.25, 0.35],
				["m026", "Nia Mensah",           "member", 0.15, 0.45, 0.20],
				["m027", "Omar Farouk",          "member", 0.40, 0.20, 0.25],
				["m028", "Priya Sharma",         "member", 0.15, 0.35, 0.30],
				["m029", "Quinn O'Brien",        "member", 0.30, 0.15, 0.35],
				["m030", "Rosa Gutierrez",       "member", 0.20, 0.30, 0.25],
				["m031", "Sam Achebe",           "member", 0.15, 0.15, 0.45],
				["m032", "Tara Singh",           "member", 0.10, 0.40, 0.15],
				["m033", "Uri Goldberg",         "member", 0.35, 0.15, 0.20],
				["m034", "Valentina Costa",      "member", 0.15, 0.30, 0.25],
				["m035", "Wei Zhang",            "member", 0.25, 0.20, 0.35],
				["m036", "Eleni Papadopoulos",   "member", 0.40, 0.15, 0.15],
				["m037", "Kwame Asante",         "member", 0.10, 0.40, 0.20],
				["m038", "Astrid Lindgren",      "member", 0.15, 0.15, 0.40],
				["m039", "Ravi Kapoor",          "member", 0.35, 0.20, 0.15],
				["m040", "Nadia Petrov",         "member", 0.15, 0.35, 0.20],
				["m041", "Javier Morales",       "member", 0.20, 0.10, 0.35],
				["m042", "Asha Nair",            "member", 0.30, 0.15, 0.20],
				["m043", "Pierre Dubois",        "member", 0.10, 0.30, 0.25],
				["m044", "Hana Novak",           "member", 0.20, 0.15, 0.30],
				["m045", "Kofi Mensah",          "member", 0.25, 0.20, 0.10],
				["m046", "Isabella Romano",      "member", 0.10, 0.30, 0.15],
				["m047", "Lars Eriksson",        "member", 0.15, 0.10, 0.30],
				["m048", "Miriam Bauer",         "member", 0.30, 0.15, 0.25],
				["m049", "Kwesi Boateng",        "member", 0.15, 0.35, 0.15],
				["m050", "Sonia Pereira",        "member", 0.20, 0.15, 0.35],
				// ── Viewers (m051-m150): lower weights ──
				["m051", "Aiden Murphy",         "viewer", 0.10, 0.08, 0.05],
				["m052", "Bianca Rossi",         "viewer", 0.05, 0.12, 0.06],
				["m053", "Carlos Vega",          "viewer", 0.08, 0.05, 0.10],
				["m054", "Diana Popescu",        "viewer", 0.12, 0.06, 0.05],
				["m055", "Erik Johansson",       "viewer", 0.05, 0.10, 0.08],
				["m056", "Fiona Walsh",          "viewer", 0.06, 0.05, 0.12],
				["m057", "Gustavo Lima",         "viewer", 0.10, 0.08, 0.06],
				["m058", "Helen Payne",          "viewer", 0.05, 0.12, 0.08],
				["m059", "Ivan Kozlov",          "viewer", 0.08, 0.05, 0.10],
				["m060", "Julia Fernández",      "viewer", 0.12, 0.10, 0.05],
				["m061", "Kenji Yamamoto",       "viewer", 0.05, 0.06, 0.12],
				["m062", "Luna Martínez",        "viewer", 0.08, 0.10, 0.06],
				["m063", "Mateo Cruz",           "viewer", 0.10, 0.05, 0.08],
				["m064", "Nina Kowalski",        "viewer", 0.06, 0.12, 0.05],
				["m065", "Oscar Blom",           "viewer", 0.05, 0.08, 0.10],
				["m066", "Petra Schwarzer",      "viewer", 0.12, 0.05, 0.06],
				["m067", "Ricardo Alves",        "viewer", 0.08, 0.10, 0.05],
				["m068", "Sofia Andersson",      "viewer", 0.05, 0.06, 0.12],
				["m069", "Tariq Ahmed",          "viewer", 0.10, 0.08, 0.06],
				["m070", "Uma Krishnan",         "viewer", 0.06, 0.05, 0.10],
				["m071", "Viktor Novak",         "viewer", 0.08, 0.12, 0.05],
				["m072", "Wendy Chu",            "viewer", 0.05, 0.10, 0.08],
				["m073", "Xander Visser",        "viewer", 0.10, 0.05, 0.06],
				["m074", "Yasmin El-Amin",       "viewer", 0.06, 0.08, 0.12],
				["m075", "Zoran Petrović",       "viewer", 0.12, 0.06, 0.05],
				["m076", "Ada Lovelace",         "viewer", 0.05, 0.10, 0.08],
				["m077", "Bruno Martins",        "viewer", 0.08, 0.05, 0.12],
				["m078", "Clara Bianchi",        "viewer", 0.10, 0.12, 0.06],
				["m079", "Daniel Okafor",        "viewer", 0.06, 0.08, 0.10],
				["m080", "Emilia Sánchez",       "viewer", 0.12, 0.05, 0.08],
				["m081", "Felix Braun",          "viewer", 0.05, 0.10, 0.06],
				["m082", "Greta Holm",           "viewer", 0.08, 0.06, 0.12],
				["m083", "Hugo Perrin",          "viewer", 0.10, 0.08, 0.05],
				["m084", "Isla Campbell",        "viewer", 0.06, 0.12, 0.10],
				["m085", "Jan Kowalczyk",        "viewer", 0.05, 0.10, 0.08],
				["m086", "Kira Sokolova",        "viewer", 0.12, 0.05, 0.06],
				["m087", "Luca Ferrari",         "viewer", 0.08, 0.10, 0.05],
				["m088", "Mila Horvat",          "viewer", 0.05, 0.06, 0.12],
				["m089", "Nils Hedberg",         "viewer", 0.10, 0.08, 0.06],
				["m090", "Olivia Jensen",        "viewer", 0.06, 0.05, 0.10],
				["m091", "Pavel Dvořák",         "viewer", 0.08, 0.12, 0.05],
				["m092", "Rosa Delgado",         "viewer", 0.05, 0.10, 0.08],
				["m093", "Stefan Ionescu",       "viewer", 0.10, 0.05, 0.12],
				["m094", "Teresa Gomes",         "viewer", 0.06, 0.08, 0.10],
				["m095", "Udo Fischer",          "viewer", 0.12, 0.06, 0.05],
				["m096", "Vera Smirnova",        "viewer", 0.05, 0.10, 0.06],
				["m097", "William Park",         "viewer", 0.08, 0.05, 0.12],
				["m098", "Xia Chen",             "viewer", 0.10, 0.12, 0.08],
				["m099", "Youssef Karam",        "viewer", 0.06, 0.08, 0.05],
				["m100", "Zlata Bogdanović",     "viewer", 0.12, 0.05, 0.10],
				["m101", "Arjun Mehta",          "viewer", 0.05, 0.10, 0.06],
				["m102", "Beatriz Nunes",        "viewer", 0.08, 0.06, 0.12],
				["m103", "Conrad Lehmann",       "viewer", 0.10, 0.08, 0.05],
				["m104", "Dahlia Osman",         "viewer", 0.06, 0.12, 0.10],
				["m105", "Elio Conti",           "viewer", 0.05, 0.10, 0.08],
				["m106", "Freya Bergman",        "viewer", 0.12, 0.05, 0.06],
				["m107", "George Adamu",         "viewer", 0.08, 0.10, 0.05],
				["m108", "Hilde Strand",         "viewer", 0.05, 0.06, 0.12],
				["m109", "Isak Nilsson",         "viewer", 0.10, 0.08, 0.06],
				["m110", "Jade Thompson",        "viewer", 0.06, 0.05, 0.10],
				["m111", "Karim Bouzid",         "viewer", 0.08, 0.12, 0.05],
				["m112", "Leila Sharif",         "viewer", 0.05, 0.10, 0.08],
				["m113", "Marco Colombo",        "viewer", 0.10, 0.05, 0.12],
				["m114", "Naomi Okeke",          "viewer", 0.06, 0.08, 0.10],
				["m115", "Otto Muller",          "viewer", 0.12, 0.06, 0.05],
				["m116", "Pilar Reyes",          "viewer", 0.05, 0.10, 0.06],
				["m117", "Ragnar Haugen",        "viewer", 0.08, 0.05, 0.12],
				["m118", "Selma Kaya",           "viewer", 0.10, 0.12, 0.08],
				["m119", "Theo Laurent",         "viewer", 0.06, 0.08, 0.05],
				["m120", "Ulrike Becker",        "viewer", 0.12, 0.05, 0.10],
				["m121", "Vito Moretti",         "viewer", 0.05, 0.10, 0.06],
				["m122", "Wanda Kwiatkowska",    "viewer", 0.08, 0.06, 0.12],
				["m123", "Xavier Dumont",        "viewer", 0.10, 0.08, 0.05],
				["m124", "Yara Costa",           "viewer", 0.06, 0.12, 0.10],
				["m125", "Zane Mitchell",        "viewer", 0.05, 0.10, 0.08],
				["m126", "Anya Volkov",          "viewer", 0.12, 0.05, 0.06],
				["m127", "Bastian Krüger",       "viewer", 0.08, 0.10, 0.05],
				["m128", "Celeste Dupont",       "viewer", 0.05, 0.06, 0.12],
				["m129", "Dario Mancini",        "viewer", 0.10, 0.08, 0.06],
				["m130", "Elena Todorov",        "viewer", 0.06, 0.05, 0.10],
				["m131", "Finn O'Sullivan",      "viewer", 0.08, 0.12, 0.05],
				["m132", "Giulia Rizzo",         "viewer", 0.05, 0.10, 0.08],
				["m133", "Henrik Dahl",          "viewer", 0.10, 0.05, 0.12],
				["m134", "Irene Papazoglou",     "viewer", 0.06, 0.08, 0.10],
				["m135", "Jakob Andersen",       "viewer", 0.12, 0.06, 0.05],
				["m136", "Kamila Szymańska",     "viewer", 0.05, 0.10, 0.06],
				["m137", "Leon Hartmann",        "viewer", 0.08, 0.05, 0.12],
				["m138", "Maria Alonso",         "viewer", 0.10, 0.12, 0.08],
				["m139", "Noah Bakker",          "viewer", 0.06, 0.08, 0.05],
				["m140", "Olga Fedorova",        "viewer", 0.12, 0.05, 0.10],
				["m141", "Patrick Byrne",        "viewer", 0.05, 0.10, 0.06],
				["m142", "Renata Vlad",          "viewer", 0.08, 0.06, 0.12],
				["m143", "Sven Lund",            "viewer", 0.10, 0.08, 0.05],
				["m144", "Tatiana Morozova",     "viewer", 0.06, 0.12, 0.10],
				["m145", "Umberto Greco",        "viewer", 0.05, 0.10, 0.08],
				["m146", "Violeta Stoica",       "viewer", 0.12, 0.05, 0.06],
				["m147", "Walter Schmidt",       "viewer", 0.08, 0.10, 0.05],
				["m148", "Xiomara Ríos",         "viewer", 0.05, 0.06, 0.12],
				["m149", "Yannick Morel",        "viewer", 0.10, 0.08, 0.06],
				["m150", "Zuzana Horváthová",    "viewer", 0.06, 0.05, 0.10],
			];

			for (const [id, name, role, govW, finW, devW] of members) {
				const avgTrust = (govW + finW + devW) / 3;
				nodes.push({
					id, label: name, type: "rspace_user" as any,
					data: {
						role,
						trustScore: Math.round(avgTrust * 100) / 100,
						delegatedWeight: Math.round(Math.max(govW, finW, devW) * 100) / 100,
						trustScores: { "gov-ops": govW, "fin-ops": finW, "dev-ops": devW },
					},
				});
			}

			// ── Generate delegation edges deterministically ──
			function generateDemoDelegations(
				members: Array<[string, string, string, number, number, number]>,
				authority: string,
				weightIdx: number, // 3=gov, 4=fin, 5=dev
				seed: number,
			): Array<[string, string, number]> {
				const rng = mulberry32(seed);
				const result: Array<[string, string, number]> = [];
				const outboundSum = new Map<string, number>();

				const admins = members.filter(m => m[2] === "admin");
				const mems = members.filter(m => m[2] === "member");
				const viewers = members.filter(m => m[2] === "viewer");

				// Viewers → members/admins (2-3 edges each)
				for (const v of viewers) {
					const edgeCount = 2 + (rng() < 0.4 ? 1 : 0);
					const targets = [...mems, ...admins].sort(() => rng() - 0.5).slice(0, edgeCount);
					let remaining = Math.min(v[weightIdx] as number, 0.9);
					for (let i = 0; i < targets.length && remaining > 0.02; i++) {
						const w = i < targets.length - 1 ? Math.round(rng() * remaining * 0.6 * 100) / 100 : Math.round(remaining * 100) / 100;
						const clamped = Math.min(w, remaining);
						if (clamped > 0.01) {
							result.push([v[0], targets[i][0], clamped]);
							remaining -= clamped;
							outboundSum.set(v[0], (outboundSum.get(v[0]) || 0) + clamped);
						}
					}
				}

				// Members → admins (3-4 edges each)
				for (const m of mems) {
					const edgeCount = 3 + (rng() < 0.3 ? 1 : 0);
					const targets = [...admins].sort(() => rng() - 0.5).slice(0, edgeCount);
					let remaining = Math.min(m[weightIdx] as number, 0.95) - (outboundSum.get(m[0]) || 0);
					for (let i = 0; i < targets.length && remaining > 0.02; i++) {
						const w = i < targets.length - 1 ? Math.round(rng() * remaining * 0.5 * 100) / 100 : Math.round(remaining * 100) / 100;
						const clamped = Math.min(w, remaining);
						if (clamped > 0.01) {
							result.push([m[0], targets[i][0], clamped]);
							remaining -= clamped;
							outboundSum.set(m[0], (outboundSum.get(m[0]) || 0) + clamped);
						}
					}
				}

				// Admins → other admins (1-2 edges each)
				for (const a of admins) {
					const edgeCount = 1 + (rng() < 0.4 ? 1 : 0);
					const targets = admins.filter(t => t[0] !== a[0]).sort(() => rng() - 0.5).slice(0, edgeCount);
					let remaining = Math.min((a[weightIdx] as number) * 0.4, 0.5) - (outboundSum.get(a[0]) || 0);
					for (let i = 0; i < targets.length && remaining > 0.02; i++) {
						const w = Math.round(Math.min(rng() * 0.3 + 0.05, remaining) * 100) / 100;
						if (w > 0.01) {
							result.push([a[0], targets[i][0], w]);
							remaining -= w;
						}
					}
				}

				return result;
			}

			const govDelegations = generateDemoDelegations(members, "gov-ops", 3, 42);
			const finDelegations = generateDemoDelegations(members, "fin-ops", 4, 137);
			const devDelegations = generateDemoDelegations(members, "dev-ops", 5, 271);

			for (const [from, to, weight] of govDelegations) {
				edges.push({ source: from, target: to, type: "delegates_to", weight, authority: "gov-ops" } as any);
			}
			for (const [from, to, weight] of finDelegations) {
				edges.push({ source: from, target: to, type: "delegates_to", weight, authority: "fin-ops" } as any);
			}
			for (const [from, to, weight] of devDelegations) {
				edges.push({ source: from, target: to, type: "delegates_to", weight, authority: "dev-ops" } as any);
			}

			for (const n of nodes) nodeIds.add(n.id);
		} else {
			const data = await twentyQuery(`{
				people(first: 200) {
					edges { node { id name { firstName lastName } emails { primaryEmail } city company { id name } } }
				}
				companies(first: 200) {
					edges { node { id name domainName { primaryLinkUrl } employees address { addressCity addressCountry } } }
				}
				opportunities(first: 200) {
					edges { node { id name stage amount { amountMicros currencyCode } company { id name } pointOfContact { id name { firstName lastName } } } }
				}
			}`, undefined, dataSpace);

			if (data) {
				const d = data as any;
				for (const { node: p } of d.people?.edges || []) {
					const label = [p.name?.firstName, p.name?.lastName].filter(Boolean).join(" ") || "Unknown";
					nodes.push({ id: p.id, label, type: "person", data: { email: p.emails?.primaryEmail, location: p.city } });
					nodeIds.add(p.id);
					if (p.company?.id) edges.push({ source: p.id, target: p.company.id, type: "works_at" });
				}
				for (const { node: co } of d.companies?.edges || []) {
					nodes.push({ id: co.id, label: co.name || "Unknown", type: "company", data: { domain: co.domainName?.primaryLinkUrl, employees: co.employees, location: co.address?.addressCity } });
					nodeIds.add(co.id);
				}
				for (const { node: opp } of d.opportunities?.edges || []) {
					nodes.push({ id: opp.id, label: opp.name || "Opportunity", type: "opportunity", data: { stage: opp.stage, amount: opp.amount } });
					nodeIds.add(opp.id);
					if (opp.company?.id && nodeIds.has(opp.company.id)) edges.push({ source: opp.id, target: opp.company.id, type: "involves" });
					if (opp.pointOfContact?.id && nodeIds.has(opp.pointOfContact.id)) edges.push({ source: opp.pointOfContact.id, target: opp.id, type: "involved_in" });
				}
			}
		}

		// Always fetch EncryptID members for space context
		// Delegations + trust scores remain gated behind trust=true
		const isAllAuthority = authority === "all";
		type UserEntry = { did: string; username: string; displayName: string | null; email?: string; trustScores: Record<string, number> };
		let spaceUsers: UserEntry[] = [];

		try {
			const usersRes = await fetch(
				`${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(trustSpace)}`,
				{ signal: AbortSignal.timeout(5000) },
			);
			if (usersRes.ok) {
				const userData = await usersRes.json() as { users: UserEntry[] };
				spaceUsers = userData.users || [];
			}
		} catch (e) { console.error("[Network] Member fetch error:", e); }

		if (spaceUsers.length > 0) {
			// Add synthetic space hub node
			const spaceHubId = `space:${trustSpace}`;
			if (!nodeIds.has(spaceHubId)) {
				nodes.push({ id: spaceHubId, label: trustSpace, type: "space" as any, data: {} });
				nodeIds.add(spaceHubId);
			}

			// Collect CRM people for name/email matching
			const crmPeople = nodes.filter(n => n.type === "person");

			for (const u of spaceUsers) {
				if (!nodeIds.has(u.did)) {
					let trustScore = 0;
					if (includeTrust) {
						if (isAllAuthority && u.trustScores) {
							const vals = Object.values(u.trustScores).filter(v => v > 0);
							trustScore = vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : 0;
						} else {
							trustScore = u.trustScores?.[authority] ?? 0;
						}
					}
					nodes.push({
						id: u.did,
						label: u.displayName || u.username,
						type: "rspace_user" as any,
						data: { trustScore, authority: isAllAuthority ? "all" : authority, role: "member" },
					});
					nodeIds.add(u.did);
				}

				// member_of edge: member → space hub
				edges.push({ source: u.did, target: spaceHubId, type: "member_of" });

				// Auto-match member to CRM person by name or email
				const uName = (u.displayName || u.username || "").toLowerCase().trim();
				const uFirst = uName.split(/\s+/)[0];
				for (const crmNode of crmPeople) {
					const crmName = crmNode.label.toLowerCase().trim();
					const crmFirst = crmName.split(/\s+/)[0];
					const nameMatch = uName && crmName && (uName === crmName || (uFirst.length > 2 && uFirst === crmFirst));
					const emailMatch = u.email && (crmNode.data as any)?.email && u.email.toLowerCase() === ((crmNode.data as any).email as string).toLowerCase();
					if (nameMatch || emailMatch) {
						edges.push({ source: u.did, target: crmNode.id, type: "member_is" });
					}
				}
			}
		}

		// If trust=true, also fetch delegation edges + trust scores
		if (includeTrust) {
			try {
				const delegUrl = new URL(`${ENCRYPTID_URL}/api/delegations/space`);
				delegUrl.searchParams.set("space", trustSpace);
				if (!isAllAuthority) delegUrl.searchParams.set("authority", authority);

				const fetches: Promise<Response>[] = [
					fetch(delegUrl, { signal: AbortSignal.timeout(5000) }),
				];
				if (isAllAuthority) {
					for (const a of ["gov-ops", "fin-ops", "dev-ops"]) {
						fetches.push(fetch(`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(a)}`, { signal: AbortSignal.timeout(5000) }));
					}
				} else {
					fetches.push(fetch(`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(trustSpace)}&authority=${encodeURIComponent(authority)}`, { signal: AbortSignal.timeout(5000) }));
				}

				const responses = await Promise.all(fetches);
				const [delegRes, ...scoreResponses] = responses;

				// Merge trust scores
				const trustMap = new Map<string, number>();
				for (const scoresRes of scoreResponses) {
					if (scoresRes.ok) {
						const scoreData = await scoresRes.json() as { scores: Array<{ did: string; totalScore: number }> };
						for (const s of scoreData.scores || []) {
							const existing = trustMap.get(s.did) || 0;
							trustMap.set(s.did, isAllAuthority ? Math.max(existing, s.totalScore) : s.totalScore);
						}
					}
				}
				for (const node of nodes) {
					if (trustMap.has(node.id)) {
						(node.data as any).trustScore = trustMap.get(node.id);
					}
				}

				// Add delegation edges
				if (delegRes.ok) {
					const delegData = await delegRes.json() as { delegations: Array<{ from: string; to: string; authority: string; weight: number }> };
					for (const d of delegData.delegations || []) {
						if (nodeIds.has(d.from) && nodeIds.has(d.to)) {
							edges.push({ source: d.from, target: d.to, type: "delegates_to", weight: d.weight, authority: d.authority } as any);
						}
					}
				}
			} catch (e) { console.error("[Network] Trust enrichment error:", e); }
		}

		const result = { nodes, edges, demo: isDemoData && !includeTrust };
		graphCaches.set(cacheKey, { data: result, ts: Date.now() });
		c.header("Cache-Control", "public, max-age=60");
		return c.json(result);
	} catch (e) {
		console.error("[Network] Graph fetch error:", e);
		return c.json({ nodes: [], edges: [], error: "Graph fetch failed" });
	}
});

// ── API: Workspaces ──
routes.get("/api/workspaces", (c) => {
	return c.json([
		{ slug: "demo", name: "Demo Network", nodeCount: 0, edgeCount: 0 },
	]);
});

// ── API: Opportunities ──
routes.get("/api/opportunities", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const token = getTokenForSpace(dataSpace);
	const data = await twentyQuery(`{
		opportunities(first: 200) {
			edges {
				node {
					id
					name
					stage
					amount { amountMicros currencyCode }
					company { id name }
					pointOfContact { id name { firstName lastName } }
					createdAt
					closeDate
				}
			}
		}
	}`, undefined, dataSpace);
	if (!data) return c.json({ opportunities: [], error: token ? "Twenty API error" : "Twenty not configured" });
	const opportunities = ((data as any).opportunities?.edges || []).map((e: any) => e.node);
	c.header("Cache-Control", "public, max-age=60");
	return c.json({ opportunities });
});

// ── Graph tabs (main view) ──
const GRAPH_TABS = [
	{ id: "members", label: "Members" },
	{ id: "trust", label: "Trust" },
	{ id: "power", label: "Power" },
	{ id: "crm", label: "CRM" },
] as const;

const GRAPH_TAB_IDS = new Set(GRAPH_TABS.map(t => t.id));

// ── CRM sub-route — API-driven CRM view ──
const CRM_TABS = [
	{ id: "pipeline", label: "Pipeline" },
	{ id: "contacts", label: "Contacts" },
	{ id: "companies", label: "Companies" },
	{ id: "graph", label: "Graph" },
	{ id: "delegations", label: "Delegations" },
] as const;

const CRM_TAB_IDS = new Set(CRM_TABS.map(t => t.id));

function renderCrm(space: string, activeTab: string, isSubdomain: boolean) {
	return renderShell({
		title: `${space} — CRM | rSpace`,
		moduleId: "rnetwork",
		spaceSlug: space,
		modules: getModuleInfoList(),
		body: `<folk-crm-view space="${space}"></folk-crm-view>`,
		scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js?v=3"></script>
<script type="module" src="/modules/rnetwork/folk-delegation-manager.js?v=2"></script>
<script type="module" src="/modules/rnetwork/folk-trust-sankey.js?v=2"></script>`,
		styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
		tabs: [...CRM_TABS],
		activeTab,
		tabBasePath: isSubdomain ? `/rnetwork/crm` : `/${space}/rnetwork/crm`,
	});
}

routes.get("/crm", (c) => {
	const space = c.req.param("space") || "demo";
	return c.html(renderCrm(space, "pipeline", c.get("isSubdomain")));
});

// Tab subpath routes: /crm/:tabId
routes.get("/crm/:tabId", (c) => {
	const space = c.req.param("space") || "demo";
	const tabId = c.req.param("tabId");
	if (!CRM_TAB_IDS.has(tabId as any)) {
		return c.redirect(c.get("isSubdomain") ? `/rnetwork/crm` : `/${space}/rnetwork/crm`, 302);
	}
	return c.html(renderCrm(space, tabId, c.get("isSubdomain")));
});

// ── API: Power Indices — proxy to EncryptID ──
routes.get("/api/power-indices", async (c) => {
	const space = getTrustSpace(c);
	const authority = c.req.query("authority") || "gov-ops";
	try {
		const res = await fetch(
			`${ENCRYPTID_URL}/api/power-indices?space=${encodeURIComponent(space)}&authority=${encodeURIComponent(authority)}`,
			{ signal: AbortSignal.timeout(5000) },
		);
		if (!res.ok) return c.json({ results: [], authority, error: "Power indices unavailable" });
		return c.json(await res.json());
	} catch {
		return c.json({ results: [], authority, error: "EncryptID unreachable" });
	}
});

routes.post("/api/power-indices/simulate", async (c) => {
	const auth = c.req.header("Authorization");
	if (!auth) return c.json({ error: "Unauthorized" }, 401);
	try {
		const body = await c.req.json();
		const res = await fetch(`${ENCRYPTID_URL}/api/power-indices/simulate`, {
			method: "POST",
			headers: { "Content-Type": "application/json", "Authorization": auth },
			body: JSON.stringify(body),
			signal: AbortSignal.timeout(5000),
		});
		return c.json(await res.json(), res.status as any);
	} catch {
		return c.json({ error: "EncryptID unreachable" }, 502);
	}
});

// ── Graph sub-tab routes ──
function renderGraph(space: string, activeTab: string, isSubdomain: boolean) {
	return renderShell({
		title: `${space} — Network | rSpace`,
		moduleId: "rnetwork",
		spaceSlug: space,
		modules: getModuleInfoList(),
		head: GRAPH3D_HEAD,
		body: `<folk-graph-viewer space="${space}" active-tab="${activeTab}"></folk-graph-viewer>`,
		scripts: `<script type="module" src="/modules/rnetwork/folk-graph-viewer.js?v=3"></script>`,
		styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
		tabs: [...GRAPH_TABS],
		activeTab,
		tabBasePath: isSubdomain ? `/rnetwork` : `/${space}/rnetwork`,
	});
}

routes.get("/:tabId", (c, next) => {
	const tabId = c.req.param("tabId");
	// Only handle graph tab IDs here; let other routes (crm, api, etc.) pass through
	if (!GRAPH_TAB_IDS.has(tabId as any)) return next();
	const space = c.req.param("space") || "demo";
	// "crm" tab redirects to the CRM sub-app
	if (tabId === "crm") {
		return c.redirect(c.get("isSubdomain") ? `/rnetwork/crm/pipeline` : `/${space}/rnetwork/crm/pipeline`, 302);
	}
	// All graph tabs (members, trust, power) use the graph viewer
	return c.html(renderGraph(space, tabId, c.get("isSubdomain")));
});

// ── Page route ──
routes.get("/", (c) => {
	const space = c.req.param("space") || "demo";
	const view = c.req.query("view");

	if (view === "app") {
		return c.redirect(c.get("isSubdomain") ? `/rnetwork/crm` : `/${space}/rnetwork/crm`, 301);
	}

	return c.html(renderGraph(space, "members", c.get("isSubdomain")));
});

// ── MI Data Export ──
// rNetwork doesn't have its own _syncServer; use the singleton.
import { syncServer as _syncServerSingleton } from "../../server/sync-instance";
import { networkDocId, type NetworkDoc } from "./schemas";

export interface MIContactItem {
	did: string;
	name: string;
	role: string;
	tags: string[];
}

export function getRecentContactsForMI(space: string, limit = 5): MIContactItem[] {
	const doc = _syncServerSingleton?.getDoc<NetworkDoc>(networkDocId(space));
	if (!doc?.contacts) return [];

	return Object.values(doc.contacts)
		.slice(0, limit)
		.map(c => ({
			did: c.did,
			name: c.name,
			role: c.role,
			tags: c.tags ? Array.from(c.tags) : [],
		}));
}

export const networkModule: RSpaceModule = {
	id: "rnetwork",
	name: "rNetwork",
	icon: "🌐",
	description: "Community relationship graph visualization with CRM sync",
	scoping: { defaultScope: 'global', userConfigurable: false },
	canvasShapes: ["folk-holon-explorer"],
	routes,
	landingPage: renderLanding,
	standaloneDomain: "rnetwork.online",
	externalApp: { url: "https://crm.rspace.online", name: "Twenty CRM" },
	feeds: [
		{
			id: "trust-graph",
			name: "Trust Graph",
			kind: "trust",
			description: "People, companies, and relationship edges — the community web of trust",
			filterable: true,
		},
		{
			id: "connections",
			name: "New Connections",
			kind: "trust",
			description: "Recently added people and relationship links",
		},
	],
	acceptsFeeds: ["data", "trust", "governance"],
	subPageInfos: [
		{
			path: "crm",
			title: "Community CRM",
			icon: "📇",
			tagline: "rNetwork Tool",
			description: "Full-featured CRM for community relationship management — contacts, companies, deals, and pipelines powered by Twenty CRM.",
			features: [
				{ icon: "👤", title: "Contact Management", text: "Track people, organizations, and their roles in your community." },
				{ icon: "🔗", title: "Relationship Graph", text: "Visualize how members connect and identify key connectors." },
				{ icon: "📊", title: "Pipeline Tracking", text: "Manage opportunities and partnerships through customizable stages." },
			],
		},
	],
};
