/**
 * Cal module — temporal coordination calendar.
 *
 * Group calendars with lunar/solar/seasonal time systems,
 * location-aware events, and temporal-spatial zoom coupling.
 *
 * All persistence uses Automerge documents via SyncServer —
 * no PostgreSQL dependency.
 */

import { Hono } from "hono";
import * as Automerge from "@automerge/automerge";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth";
import { resolveCallerRole } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces";
import { filterArrayByVisibility } from "../../shared/membrane";
import type { ObjectVisibility } from "../../shared/membrane";
import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import { calendarSchema, calendarDocId } from './schemas';
import type { CalendarDoc, CalendarEvent, CalendarSource, SavedCalendarView, ScheduledItemMetadata, EventAttendee } from './schemas';
import { sendMagicLinks } from "../../server/magic-link/send";
import {
	getValidGoogleToken, listGoogleCalendars, fetchGoogleEvents,
	createGoogleEvent, mapGoogleEventToCalendar, mapCalendarEventToGoogle,
} from '../../server/google-calendar';

let _syncServer: SyncServer | null = null;

const routes = new Hono();

// ── Local-first helpers ──

/**
 * Lazily create the calendar Automerge doc if it doesn't exist yet.
 * Returns the current (immutable) doc snapshot.
 */
function ensureDoc(space: string): CalendarDoc {
	const docId = calendarDocId(space);
	let doc = _syncServer!.getDoc<CalendarDoc>(docId);
	if (!doc) {
		doc = Automerge.change(Automerge.init<CalendarDoc>(), 'init calendar', (d) => {
			const init = calendarSchema.init();
			d.meta = init.meta;
			d.meta.spaceSlug = space;
			d.sources = {};
			d.events = {};
			d.views = {};
		});
		_syncServer!.setDoc(docId, doc);
	}
	// Backward compat: old docs may lack views
	if (!doc.views) {
		_syncServer!.changeDoc<CalendarDoc>(docId, 'add views map', (d) => {
			d.views = {};
		});
		doc = _syncServer!.getDoc<CalendarDoc>(docId)!;
	}
	return doc;
}

function daysFromNow(days: number, hours: number, minutes: number): Date {
	const d = new Date();
	d.setDate(d.getDate() + days);
	d.setHours(hours, minutes, 0, 0);
	return d;
}

/**
 * Build an event row object suitable for JSON responses.
 * Maps camelCase schema fields to the snake_case format the API previously returned.
 */
function eventToRow(ev: CalendarEvent, sources: Record<string, CalendarSource>) {
	const src = ev.sourceId ? sources[ev.sourceId] : undefined;
	return {
		id: ev.id,
		title: ev.title,
		description: ev.description,
		start_time: ev.startTime ? new Date(ev.startTime).toISOString() : null,
		end_time: ev.endTime ? new Date(ev.endTime).toISOString() : null,
		all_day: ev.allDay,
		timezone: ev.timezone,
		rrule: ev.rrule,
		status: ev.status,
		visibility: ev.visibility,
		source_id: ev.sourceId,
		source_name: src?.name ?? ev.sourceName ?? null,
		source_color: src?.color ?? ev.sourceColor ?? null,
		source_type: src?.sourceType ?? ev.sourceType ?? null,
		location_id: ev.locationId,
		location_name: ev.locationName,
		location_label: ev.locationName,
		location_lat: ev.locationLat,
		location_lng: ev.locationLng,
		location_granularity: ev.locationGranularity,
		location_breadcrumb: ev.locationBreadcrumb,
		booking_status: ev.bookingStatus,
		is_virtual: ev.isVirtual,
		virtual_url: ev.virtualUrl,
		virtual_platform: ev.virtualPlatform,
		r_tool_source: ev.rToolSource,
		r_tool_entity_id: ev.rToolEntityId,
		tags: ev.tags ? Array.from(ev.tags) : null,
		attendees: ev.attendees,
		attendee_count: ev.attendeeCount,
		metadata: ev.metadata,
		created_at: ev.createdAt ? new Date(ev.createdAt).toISOString() : null,
		updated_at: ev.updatedAt ? new Date(ev.updatedAt).toISOString() : null,
	};
}

/**
 * Build a source row object for JSON responses.
 */
function sourceToRow(src: CalendarSource) {
	return {
		id: src.id,
		name: src.name,
		source_type: src.sourceType,
		url: src.url,
		color: src.color,
		is_active: src.isActive,
		is_visible: src.isVisible,
		sync_interval_minutes: src.syncIntervalMinutes,
		last_synced_at: src.lastSyncedAt ? new Date(src.lastSyncedAt).toISOString() : null,
		owner_id: src.ownerId,
		created_at: src.createdAt ? new Date(src.createdAt).toISOString() : null,
		external_id: src.externalId || null,
		sync_token: src.syncToken ? '(set)' : null,
		last_sync_status: src.lastSyncStatus || null,
		last_sync_error: src.lastSyncError || null,
		event_count: src.eventCount ?? null,
	};
}

/**
 * Seed demo data if the doc has no events yet.
 */
function seedDemoIfEmpty(space: string) {
	const docId = calendarDocId(space);
	const doc = ensureDoc(space);
	if ((doc.meta as any)?.seeded || Object.keys(doc.events).length > 0) return;

	_syncServer!.changeDoc<CalendarDoc>(docId, 'seed demo data', (d) => {
		const now = Date.now();

		// Create calendar sources
		const communityId = crypto.randomUUID();
		const sprintsId = crypto.randomUUID();

		d.sources[communityId] = {
			id: communityId,
			name: 'Community Events',
			sourceType: 'MANUAL',
			url: null,
			color: '#6366f1',
			isActive: true,
			isVisible: true,
			syncIntervalMinutes: null,
			lastSyncedAt: 0,
			ownerId: null,
			createdAt: now,
		};
		d.sources[sprintsId] = {
			id: sprintsId,
			name: 'Development Sprints',
			sourceType: 'MANUAL',
			url: null,
			color: '#f59e0b',
			isActive: true,
			isVisible: true,
			syncIntervalMinutes: null,
			lastSyncedAt: 0,
			ownerId: null,
			createdAt: now,
		};

		// Location IDs (embedded on events, no separate locations table)
		const berlinLocId = crypto.randomUUID();

		// Seed events
		const seedEvents: Array<{
			title: string; desc: string; start: Date; end: Date;
			sourceId: string; allDay?: boolean;
			locationId?: string; locationName?: string;
			locationLat?: number; locationLng?: number; locationGranularity?: string;
			isVirtual?: boolean; virtualUrl?: string; virtualPlatform?: string;
			tags?: string[];
		}> = [
			{
				title: "rSpace Launch Party",
				desc: "Celebrating the launch of the unified rSpace platform with all 22 modules live.",
				start: daysFromNow(-21, 18, 0), end: daysFromNow(-21, 22, 0),
				sourceId: communityId, locationName: "Radiant Hall, Pittsburgh",
				tags: ["launch", "community"],
			},
			{
				title: "Provider Onboarding Workshop",
				desc: "Hands-on session for print providers joining the cosmolocal network.",
				start: daysFromNow(-12, 14, 0), end: daysFromNow(-12, 17, 0),
				sourceId: communityId, isVirtual: true,
				virtualUrl: "https://meet.jit.si/rspace-providers", virtualPlatform: "Jitsi",
				tags: ["onboarding", "cosmolocal"],
			},
			{
				title: "Weekly Community Standup",
				desc: "Open standup — share what you're working on, ask for help, coordinate.",
				start: daysFromNow(0, 16, 0), end: daysFromNow(0, 16, 45),
				sourceId: communityId, isVirtual: true,
				virtualUrl: "https://meet.jit.si/rspace-standup", virtualPlatform: "Jitsi",
				tags: ["recurring", "community"],
			},
			{
				title: "Sprint: Module Seeding & Polish",
				desc: "Focus sprint on populating demo data and improving UX across all modules.",
				start: daysFromNow(0, 9, 0), end: daysFromNow(5, 18, 0),
				sourceId: sprintsId, allDay: true,
				tags: ["sprint", "dev"],
			},
			{
				title: "rFlows Budget Review",
				desc: "Quarterly review of treasury flows, enoughness thresholds, and overflow routing.",
				start: daysFromNow(6, 15, 0), end: daysFromNow(6, 17, 0),
				sourceId: communityId, isVirtual: true,
				virtualUrl: "https://meet.jit.si/rflows-review", virtualPlatform: "Jitsi",
				tags: ["governance", "finance"],
			},
			{
				title: "Cosmolocal Design Sprint",
				desc: "Two-day design sprint on the next generation of cosmolocal tooling.",
				start: daysFromNow(11, 9, 0), end: daysFromNow(12, 18, 0),
				sourceId: sprintsId,
				locationId: berlinLocId, locationName: "Druckwerkstatt Berlin",
				locationLat: 52.52, locationLng: 13.405, locationGranularity: "city",
				tags: ["sprint", "design", "travel"],
			},
			{
				title: "Q1 Retrospective",
				desc: "Looking back at what we built, what worked, and what to improve.",
				start: daysFromNow(21, 16, 0), end: daysFromNow(21, 18, 0),
				sourceId: communityId, isVirtual: true,
				virtualUrl: "https://meet.jit.si/rspace-retro", virtualPlatform: "Jitsi",
				tags: ["recurring", "community", "retrospective"],
			},
		];

		for (const e of seedEvents) {
			const eventId = crypto.randomUUID();
			d.events[eventId] = {
				id: eventId,
				title: e.title,
				description: e.desc,
				startTime: e.start.getTime(),
				endTime: e.end.getTime(),
				allDay: e.allDay || false,
				timezone: 'UTC',
				rrule: null,
				status: null,
				visibility: null,
				sourceId: e.sourceId,
				sourceName: null,
				sourceType: null,
				sourceColor: null,
				locationId: e.locationId || null,
				locationName: e.locationName || null,
				coordinates: null,
				locationGranularity: e.locationGranularity || null,
				locationLat: e.locationLat ?? null,
				locationLng: e.locationLng ?? null,
				locationBreadcrumb: null,
				bookingStatus: null,
				isVirtual: e.isVirtual || false,
				virtualUrl: e.virtualUrl || null,
				virtualPlatform: e.virtualPlatform || null,
				rToolSource: null,
				rToolEntityId: null,
				attendees: [],
				attendeeCount: 0,
				tags: e.tags || null,
				metadata: null,
				likelihood: null,
				createdAt: now,
				updatedAt: now,
			};
		}
	});

	// Mark as seeded so deleting all events doesn't re-trigger seeding
	_syncServer!.changeDoc<CalendarDoc>(docId, 'mark seeded', (d) => {
		if (d.meta) (d.meta as any).seeded = true;
	});

	console.log("[Cal] Demo data seeded: 2 sources, 7 events");
}

// ── API: Events ──

// GET /api/events — query events with filters
routes.get("/api/events", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const { start, end, source, search, rTool, rEntityId, upcoming, tags: tagsParam } = c.req.query();

	const doc = ensureDoc(dataSpace);

	// Resolve caller role for membrane filtering
	let callerRole: SpaceRoleString = 'viewer';
	const token = extractToken(c.req.raw.headers);
	if (token) {
		try {
			const claims = await verifyToken(token);
			const resolved = await resolveCallerRole(space, claims);
			if (resolved) callerRole = resolved.role;
		} catch {}
	}
	let events = filterArrayByVisibility(
		Object.values(doc.events), callerRole,
		(e) => e.accessVisibility as ObjectVisibility | undefined,
	);

	// Apply filters
	if (tagsParam) {
		const requiredTags = tagsParam.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean);
		if (requiredTags.length > 0) {
			events = events.filter((e) => {
				const eTags = (e.tags ?? []).map((t) => t.toLowerCase());
				return requiredTags.every((rt) => eTags.includes(rt));
			});
		}
	}
	if (start) {
		const startMs = new Date(start).getTime();
		events = events.filter((e) => e.startTime >= startMs);
	}
	if (end) {
		const endMs = new Date(end).getTime() + 86400000; // +1 day
		events = events.filter((e) => e.startTime <= endMs);
	}
	if (source) {
		events = events.filter((e) => e.sourceId === source);
	}
	if (search) {
		const term = search.toLowerCase();
		events = events.filter((e) =>
			e.title.toLowerCase().includes(term) ||
			(e.description && e.description.toLowerCase().includes(term))
		);
	}
	if (rTool) {
		events = events.filter((e) => e.rToolSource === rTool);
	}
	if (rEntityId) {
		events = events.filter((e) => e.rToolEntityId === rEntityId);
	}
	if (upcoming) {
		const nowMs = Date.now();
		const futureMs = nowMs + parseInt(upcoming) * 86400000;
		events = events.filter((e) => e.startTime >= nowMs && e.startTime <= futureMs);
	}

	// Sort by start time, limit to 500
	events.sort((a, b) => a.startTime - b.startTime);
	events = events.slice(0, 500);

	const rows = events.map((e) => eventToRow(e, doc.sources));
	return c.json({ count: rows.length, results: rows });
});


// POST /api/events — create event
routes.post("/api/events", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	let claims;
	try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const body = await c.req.json();
	const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name,
		is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id,
		is_scheduled_item, provenance, item_preview, tags: bodyTags } = body;
	if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400);

	const docId = calendarDocId(dataSpace);
	ensureDoc(dataSpace);
	const eventId = crypto.randomUUID();
	const now = Date.now();

	// Build metadata for scheduled knowledge items
	let metadata: ScheduledItemMetadata | null = null;
	if (is_scheduled_item && provenance) {
		metadata = {
			isScheduledItem: true,
			provenance: {
				rid: provenance.rid || '',
				contentHash: provenance.content_hash ?? provenance.contentHash ?? null,
				sourceType: provenance.source_type ?? provenance.sourceType ?? '',
				sourceSpace: provenance.source_space ?? provenance.sourceSpace ?? space,
				lifecycleEvent: provenance.lifecycle_event ?? provenance.lifecycleEvent ?? 'NEW',
				originalCreatedAt: provenance.original_created_at ?? provenance.originalCreatedAt ?? now,
				scheduledBy: provenance.scheduled_by ?? provenance.scheduledBy ?? null,
				scheduledAt: now,
			},
			itemPreview: item_preview ? {
				textPreview: item_preview.text_preview ?? item_preview.textPreview ?? '',
				mimeType: item_preview.mime_type ?? item_preview.mimeType ?? 'text/plain',
				thumbnailUrl: item_preview.thumbnail_url ?? item_preview.thumbnailUrl,
				canvasUrl: item_preview.canvas_url ?? item_preview.canvasUrl,
				shapeSnapshot: item_preview.shape_snapshot ?? item_preview.shapeSnapshot,
			} : {
				textPreview: description || title,
				mimeType: 'text/plain',
			},
			reminderSent: false,
			reminderSentAt: null,
		};
	}

	_syncServer!.changeDoc<CalendarDoc>(docId, `create event ${eventId}`, (d) => {
		d.events[eventId] = {
			id: eventId,
			title: title.trim(),
			description: description || '',
			startTime: new Date(start_time).getTime(),
			endTime: end_time ? new Date(end_time).getTime() : 0,
			allDay: all_day || false,
			timezone: timezone || 'UTC',
			rrule: null,
			status: null,
			visibility: null,
			sourceId: source_id || null,
			sourceName: null,
			sourceType: null,
			sourceColor: null,
			locationId: location_id || null,
			locationName: location_name || null,
			coordinates: null,
			locationGranularity: null,
			locationLat: null,
			locationLng: null,
			locationBreadcrumb: null,
			bookingStatus: null,
			isVirtual: is_virtual || false,
			virtualUrl: virtual_url || null,
			virtualPlatform: virtual_platform || null,
			rToolSource: r_tool_source || 'rSchedule',
			rToolEntityId: r_tool_entity_id || null,
			attendees: [],
			attendeeCount: 0,
			tags: Array.isArray(bodyTags) ? bodyTags : null,
			metadata: metadata,
			likelihood: null,
			createdAt: now,
			updatedAt: now,
		};
	});

	const updated = _syncServer!.getDoc<CalendarDoc>(docId)!;

	// Notify space members about the new event
	import('../rinbox/agent-notify').then(({ sendSpaceNotification }) => {
		const startDate = new Date(start_time).toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
		sendSpaceNotification(dataSpace, `New Event: ${title.trim()}`,
			`<h3>${title.trim()}</h3><p><strong>When:</strong> ${startDate}</p>${description ? `<p>${description}</p>` : ''}<p><a href="https://rspace.online/${dataSpace}/rcal">View in rCal</a></p>`
		).catch(() => {});
	}).catch(() => {});

	return c.json(eventToRow(updated.events[eventId], updated.sources), 201);
});

// POST /api/import-ics — import events from an ICS file upload
routes.post("/api/import-ics", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;

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

	const text = await file.text();

	// Simple VEVENT parser — extract events from ICS text
	const vevents: Array<{ title: string; description: string; start: number; end: number; allDay: boolean; location: string }> = [];
	const eventBlocks = text.split("BEGIN:VEVENT");
	for (let i = 1; i < eventBlocks.length; i++) {
		const block = eventBlocks[i].split("END:VEVENT")[0];
		const getField = (name: string): string => {
			const match = block.match(new RegExp(`^${name}[;:](.*)$`, 'm'));
			return match ? match[1].replace(/\\n/g, '\n').replace(/\\,/g, ',').trim() : '';
		};

		const summary = getField("SUMMARY");
		if (!summary) continue;

		const dtstart = getField("DTSTART");
		const dtend = getField("DTEND");
		const description = getField("DESCRIPTION");
		const location = getField("LOCATION");

		// Parse ICS date: YYYYMMDD or YYYYMMDDTHHmmssZ
		const parseIcsDate = (s: string): number => {
			if (!s) return 0;
			// Strip VALUE=DATE: prefix etc
			const clean = s.replace(/^[^:]*:/, '').trim();
			if (clean.length === 8) {
				// All-day: YYYYMMDD
				return new Date(`${clean.slice(0,4)}-${clean.slice(4,6)}-${clean.slice(6,8)}`).getTime();
			}
			// Full datetime: YYYYMMDDTHHmmss or YYYYMMDDTHHmmssZ
			const m = clean.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})/);
			if (m) return new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`).getTime();
			return new Date(clean).getTime() || 0;
		};

		const startMs = parseIcsDate(dtstart);
		if (!startMs) continue;
		const endMs = parseIcsDate(dtend) || startMs + 3600000;
		const allDay = dtstart.includes("VALUE=DATE") || (dtstart.replace(/^[^:]*:/, '').trim().length === 8);

		vevents.push({ title: summary, description, start: startMs, end: endMs, allDay, location });
	}

	if (!vevents.length) return c.json({ error: "No events found in ICS file" }, 400);

	const docId = calendarDocId(dataSpace);
	ensureDoc(dataSpace);
	const now = Date.now();

	// Create an "Imported" source for these events
	const sourceId = crypto.randomUUID();

	_syncServer!.changeDoc<CalendarDoc>(docId, `import ${vevents.length} ICS events`, (d) => {
		d.sources[sourceId] = {
			id: sourceId,
			name: file.name.replace(/\.ics$/i, '') || 'Imported Calendar',
			sourceType: 'ICS_IMPORT',
			url: null,
			color: '#8b5cf6',
			isActive: true,
			isVisible: true,
			syncIntervalMinutes: null,
			lastSyncedAt: now,
			ownerId: null,
			createdAt: now,
		};
		for (const ev of vevents) {
			const id = crypto.randomUUID();
			d.events[id] = {
				id,
				title: ev.title,
				description: ev.description,
				startTime: ev.start,
				endTime: ev.end,
				allDay: ev.allDay,
				timezone: 'UTC',
				rrule: null,
				status: null,
				visibility: null,
				sourceId,
				sourceName: null,
				sourceType: null,
				sourceColor: null,
				locationId: null,
				locationName: ev.location || null,
				coordinates: null,
				locationGranularity: null,
				locationLat: null,
				locationLng: null,
				locationBreadcrumb: null,
				bookingStatus: null,
				isVirtual: false,
				virtualUrl: null,
				virtualPlatform: null,
				rToolSource: 'ICS_IMPORT',
				rToolEntityId: null,
				attendees: [],
				attendeeCount: 0,
				tags: null,
				metadata: null,
				likelihood: null,
				createdAt: now,
				updatedAt: now,
			};
		}
	});

	return c.json({ imported: vevents.length, sourceId });
});

// GET /api/events/scheduled — query only scheduled knowledge items
routes.get("/api/events/scheduled", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const { date, upcoming, pending_only } = c.req.query();

	const doc = ensureDoc(dataSpace);
	let events = Object.values(doc.events).filter((e) => {
		const meta = e.metadata as ScheduledItemMetadata | null;
		return meta?.isScheduledItem === true;
	});

	if (date) {
		const dayStart = new Date(date).setHours(0, 0, 0, 0);
		const dayEnd = dayStart + 86400000;
		events = events.filter((e) => e.startTime >= dayStart && e.startTime < dayEnd);
	}

	if (upcoming) {
		const nowMs = Date.now();
		const futureMs = nowMs + parseInt(upcoming) * 86400000;
		events = events.filter((e) => e.startTime >= nowMs && e.startTime <= futureMs);
	}

	if (pending_only === "true") {
		events = events.filter((e) => {
			const meta = e.metadata as ScheduledItemMetadata;
			return !meta.reminderSent;
		});
	}

	events.sort((a, b) => a.startTime - b.startTime);
	const rows = events.map((e) => eventToRow(e, doc.sources));
	return c.json({ count: rows.length, results: rows });
});

// GET /api/events/:id
routes.get("/api/events/:id", 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 ev = doc.events[id];
	if (!ev) return c.json({ error: "Event not found" }, 404);
	return c.json(eventToRow(ev, doc.sources));
});

// PATCH /api/events/:id
routes.patch("/api/events/:id", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	let claims;
	try { claims = await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	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 = calendarDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	if (!doc.events[id]) return c.json({ error: "Not found" }, 404);

	// Map of allowed body keys to CalendarEvent fields
	const fieldMap: Record<string, keyof CalendarEvent> = {
		title: 'title',
		description: 'description',
		start_time: 'startTime',
		end_time: 'endTime',
		all_day: 'allDay',
		timezone: 'timezone',
		status: 'status',
		visibility: 'visibility',
		location_name: 'locationName',
		is_virtual: 'isVirtual',
		virtual_url: 'virtualUrl',
		tags: 'tags',
		metadata: 'metadata',
	};

	const updates: Array<{ field: keyof CalendarEvent; value: any }> = [];
	for (const [bodyKey, docField] of Object.entries(fieldMap)) {
		if (body[bodyKey] !== undefined) {
			let value = body[bodyKey];
			// Convert time strings to epoch ms
			if (bodyKey === 'start_time' || bodyKey === 'end_time') {
				value = new Date(value).getTime();
			}
			updates.push({ field: docField, value });
		}
	}

	if (updates.length === 0) return c.json({ error: "No fields" }, 400);

	_syncServer!.changeDoc<CalendarDoc>(docId, `update event ${id}`, (d) => {
		const ev = d.events[id];
		for (const { field, value } of updates) {
			(ev as any)[field] = value;
		}
		ev.updatedAt = Date.now();
	});

	const updated = _syncServer!.getDoc<CalendarDoc>(docId)!;
	return c.json(eventToRow(updated.events[id], updated.sources));
});

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

	const docId = calendarDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	if (!doc.events[id]) return c.json({ error: "Not found" }, 404);

	_syncServer!.changeDoc<CalendarDoc>(docId, `delete event ${id}`, (d) => {
		delete d.events[id];
	});
	return c.json({ ok: true });
});

// ── API: RSVP Magic Links ──

// POST /api/events/:id/invite — create RSVP magic links for an event
routes.post("/api/events/:id/invite", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");

	const doc = ensureDoc(dataSpace);
	const event = doc.events[id];
	if (!event) return c.json({ error: "Event not found" }, 404);

	const body = await c.req.json<{ participants: Array<{ name: string; email: string }>; sendEmail?: boolean }>();
	if (!body.participants?.length) return c.json({ error: "participants array required" }, 400);

	const results = await sendMagicLinks({
		space: dataSpace,
		type: "rsvp",
		targetId: id,
		title: event.title,
		participants: body.participants,
		sendEmail: body.sendEmail,
	});

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

// GET /api/events/:id/rsvp — get RSVP tallies for an event
routes.get("/api/events/:id/rsvp", 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 event = doc.events[id];
	if (!event) return c.json({ error: "Event not found" }, 404);

	const attendees = (event.attendees || []) as EventAttendee[];
	const tallies = {
		yes: attendees.filter((a) => a.status === "yes").length,
		no: attendees.filter((a) => a.status === "no").length,
		maybe: attendees.filter((a) => a.status === "maybe").length,
		pending: attendees.filter((a) => a.status === "pending").length,
		total: attendees.length,
	};

	return c.json({
		eventId: id,
		title: event.title,
		tallies,
		attendees: attendees.map((a) => ({
			name: a.name,
			status: a.status,
			respondedAt: a.respondedAt ? new Date(a.respondedAt).toISOString() : null,
			source: a.source,
		})),
	});
});

// ── API: Sources ──

routes.get("/api/sources", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const { is_active, is_visible, source_type } = c.req.query();
	const doc = ensureDoc(dataSpace);

	let sources = Object.values(doc.sources);

	if (is_active !== undefined) {
		const active = is_active === "true";
		sources = sources.filter((s) => s.isActive === active);
	}
	if (is_visible !== undefined) {
		const visible = is_visible === "true";
		sources = sources.filter((s) => s.isVisible === visible);
	}
	if (source_type) {
		sources = sources.filter((s) => s.sourceType === source_type);
	}

	sources.sort((a, b) => a.name.localeCompare(b.name));
	const rows = sources.map(sourceToRow);
	return c.json({ count: rows.length, results: rows });
});

routes.post("/api/sources", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const body = await c.req.json();
	const docId = calendarDocId(dataSpace);
	ensureDoc(dataSpace);

	const sourceId = crypto.randomUUID();
	const now = Date.now();

	_syncServer!.changeDoc<CalendarDoc>(docId, `create source ${sourceId}`, (d) => {
		d.sources[sourceId] = {
			id: sourceId,
			name: body.name,
			sourceType: body.source_type || 'MANUAL',
			url: body.url || null,
			color: body.color || '#6366f1',
			isActive: body.is_active ?? true,
			isVisible: body.is_visible ?? true,
			syncIntervalMinutes: null,
			lastSyncedAt: 0,
			ownerId: null,
			createdAt: now,
		};
	});

	const updated = _syncServer!.getDoc<CalendarDoc>(docId)!;
	return c.json(sourceToRow(updated.sources[sourceId]), 201);
});

// ── API: Locations ──
// Locations are now derived from event data (no separate table).
// Each unique locationId/locationName combination is extracted from events.

interface DerivedLocation {
	id: string;
	name: string;
	granularity: number | null;
	parent_id: string | null;
	lat: number | null;
	lng: number | null;
}

function deriveLocations(doc: CalendarDoc): DerivedLocation[] {
	const seen = new Map<string, DerivedLocation>();
	for (const ev of Object.values(doc.events)) {
		const key = ev.locationId || ev.locationName;
		if (!key) continue;
		if (seen.has(key)) continue;
		seen.set(key, {
			id: ev.locationId || key,
			name: ev.locationName || key,
			granularity: ev.locationGranularity ? parseInt(ev.locationGranularity) || null : null,
			parent_id: null,
			lat: ev.locationLat,
			lng: ev.locationLng,
		});
	}
	return Array.from(seen.values());
}

routes.get("/api/locations", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const { granularity, parent, search, root } = c.req.query();
	const doc = ensureDoc(dataSpace);

	let locations = deriveLocations(doc);

	if (root === "true") {
		locations = locations.filter((l) => l.parent_id === null);
	}
	if (granularity) {
		const g = parseInt(granularity);
		locations = locations.filter((l) => l.granularity === g);
	}
	if (parent) {
		locations = locations.filter((l) => l.parent_id === parent);
	}
	if (search) {
		const term = search.toLowerCase();
		locations = locations.filter((l) => l.name.toLowerCase().includes(term));
	}

	locations.sort((a, b) => a.name.localeCompare(b.name));
	return c.json(locations);
});

routes.get("/api/locations/tree", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const doc = ensureDoc(dataSpace);

	// Flat list with depth=0 since hierarchical parent_id data is not stored in Automerge
	const locations = deriveLocations(doc).map((l) => ({ ...l, depth: 0 }));
	locations.sort((a, b) => a.name.localeCompare(b.name));
	return c.json(locations);
});

// ── API: Lunar data (computed, not stored) ──

routes.get("/api/lunar", async (c) => {
	const { start, end } = c.req.query();
	if (!start || !end) return c.json({ error: "start and end required" }, 400);

	// Simple lunar phase approximation based on synodic month
	const SYNODIC_MONTH = 29.53059;
	const KNOWN_NEW_MOON = new Date("2024-01-11T11:57:00Z").getTime();
	const phases: Record<string, { phase: string; illumination: number }> = {};

	const startDate = new Date(start);
	const endDate = new Date(end);
	const current = new Date(startDate);

	while (current <= endDate) {
		const daysSinceNewMoon = (current.getTime() - KNOWN_NEW_MOON) / (1000 * 60 * 60 * 24);
		const lunation = ((daysSinceNewMoon % SYNODIC_MONTH) + SYNODIC_MONTH) % SYNODIC_MONTH;
		const fraction = lunation / SYNODIC_MONTH;
		const illumination = 0.5 * (1 - Math.cos(2 * Math.PI * fraction));

		let phase = "waxing_crescent";
		if (fraction < 0.0625) phase = "new_moon";
		else if (fraction < 0.1875) phase = "waxing_crescent";
		else if (fraction < 0.3125) phase = "first_quarter";
		else if (fraction < 0.4375) phase = "waxing_gibbous";
		else if (fraction < 0.5625) phase = "full_moon";
		else if (fraction < 0.6875) phase = "waning_gibbous";
		else if (fraction < 0.8125) phase = "last_quarter";
		else if (fraction < 0.9375) phase = "waning_crescent";
		else phase = "new_moon";

		phases[current.toISOString().split("T")[0]] = { phase, illumination: Math.round(illumination * 100) / 100 };
		current.setDate(current.getDate() + 1);
	}

	return c.json(phases);
});

// ── API: Stats ──

routes.get("/api/stats", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const doc = ensureDoc(dataSpace);

	const events = Object.values(doc.events).length;
	const sources = Object.values(doc.sources).filter((s) => s.isActive).length;
	const locations = deriveLocations(doc).length;

	return c.json({ events, sources, locations });
});

// ── API: Context (r* tool bridge) ──

routes.get("/api/context/:tool", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const tool = c.req.param("tool");
	const entityId = c.req.query("entityId");
	if (!entityId) return c.json({ error: "entityId required" }, 400);

	const doc = ensureDoc(dataSpace);
	const matching = Object.values(doc.events)
		.filter((e) => e.rToolSource === tool && e.rToolEntityId === entityId)
		.sort((a, b) => a.startTime - b.startTime);

	const rows = matching.map((e) => eventToRow(e, doc.sources));
	return c.json({ count: rows.length, results: rows });
});

// ── API: Saved Views ──

function viewToRow(v: SavedCalendarView) {
	return {
		id: v.id,
		name: v.name,
		icon: v.icon,
		filter: v.filter,
		default_view_mode: v.defaultViewMode,
		created_at: v.createdAt ? new Date(v.createdAt).toISOString() : null,
		updated_at: v.updatedAt ? new Date(v.updatedAt).toISOString() : null,
	};
}

routes.get("/api/views", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const doc = ensureDoc(dataSpace);

	const views = Object.values(doc.views ?? {}).sort((a, b) => a.name.localeCompare(b.name));
	return c.json({ count: views.length, results: views.map(viewToRow) });
});

routes.post("/api/views", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const body = await c.req.json();
	if (!body.name?.trim()) return c.json({ error: "name required" }, 400);

	const docId = calendarDocId(dataSpace);
	ensureDoc(dataSpace);
	const viewId = crypto.randomUUID();
	const now = Date.now();

	_syncServer!.changeDoc<CalendarDoc>(docId, `create view ${viewId}`, (d) => {
		d.views[viewId] = {
			id: viewId,
			name: body.name.trim(),
			icon: body.icon || null,
			filter: body.filter || {},
			defaultViewMode: body.default_view_mode || null,
			createdAt: now,
			updatedAt: now,
		};
	});

	const updated = _syncServer!.getDoc<CalendarDoc>(docId)!;
	return c.json(viewToRow(updated.views[viewId]), 201);
});

routes.patch("/api/views/:id", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	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 = calendarDocId(dataSpace);
	const doc = ensureDoc(dataSpace);
	if (!doc.views?.[id]) return c.json({ error: "View not found" }, 404);

	_syncServer!.changeDoc<CalendarDoc>(docId, `update view ${id}`, (d) => {
		const v = d.views[id];
		if (body.name !== undefined) v.name = body.name.trim();
		if (body.icon !== undefined) v.icon = body.icon;
		if (body.filter !== undefined) v.filter = body.filter;
		if (body.default_view_mode !== undefined) v.defaultViewMode = body.default_view_mode;
		v.updatedAt = Date.now();
	});

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

routes.delete("/api/views/:id", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");

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

	_syncServer!.changeDoc<CalendarDoc>(docId, `delete view ${id}`, (d) => {
		delete d.views[id];
	});
	return c.json({ ok: true });
});

// ── Google Calendar integration ──

/** Sync a single Google Calendar source. Returns number of events upserted + deleted. */
async function syncGoogleSource(
	space: string,
	sourceId: string,
): Promise<{ upserted: number; deleted: number; error?: string }> {
	const token = await getValidGoogleToken(space, _syncServer!);
	if (!token) return { upserted: 0, deleted: 0, error: 'Google not connected' };

	const docId = calendarDocId(space);
	const doc = ensureDoc(space);
	const source = doc.sources[sourceId];
	if (!source || source.sourceType !== 'GOOGLE') {
		return { upserted: 0, deleted: 0, error: 'Source not found or not a Google source' };
	}

	// Mark as syncing
	_syncServer!.changeDoc<CalendarDoc>(docId, 'gcal sync start', (d) => {
		d.sources[sourceId].lastSyncStatus = 'syncing';
	});

	try {
		const result = await fetchGoogleEvents(token, source.externalId!, {
			syncToken: source.syncToken,
			// On full sync, fetch 1 year back + 1 year forward
			timeMin: source.syncToken ? undefined : new Date(Date.now() - 365 * 86400000).toISOString(),
			timeMax: source.syncToken ? undefined : new Date(Date.now() + 365 * 86400000).toISOString(),
		});

		let upserted = 0;
		let deleted = 0;

		_syncServer!.changeDoc<CalendarDoc>(docId, `gcal sync ${source.name}`, (d) => {
			// Upsert events
			for (const gEvent of result.events) {
				// Find existing event by Google event ID
				const existingId = Object.keys(d.events).find(
					(k) => d.events[k].rToolEntityId === gEvent.id && d.events[k].sourceId === sourceId,
				);
				const eventId = existingId || crypto.randomUUID();
				const mapped = mapGoogleEventToCalendar(gEvent, sourceId, source.name, source.color);
				const now = Date.now();

				if (existingId) {
					// Update existing event
					const existing = d.events[eventId];
					Object.assign(existing, { ...mapped, id: eventId, createdAt: existing.createdAt, updatedAt: now });
				} else {
					// Create new event
					d.events[eventId] = {
						...mapped,
						id: eventId,
						createdAt: now,
						updatedAt: now,
					} as CalendarEvent;
				}
				upserted++;
			}

			// Delete cancelled events
			for (const deletedGId of result.deleted) {
				const existingId = Object.keys(d.events).find(
					(k) => d.events[k].rToolEntityId === deletedGId && d.events[k].sourceId === sourceId,
				);
				if (existingId) {
					delete d.events[existingId];
					deleted++;
				}
			}

			// Update source metadata
			d.sources[sourceId].syncToken = result.nextSyncToken;
			d.sources[sourceId].lastSyncedAt = Date.now();
			d.sources[sourceId].lastSyncStatus = 'success';
			d.sources[sourceId].lastSyncError = null;
			// Count events from this source
			d.sources[sourceId].eventCount = Object.values(d.events).filter(
				(e) => e.sourceId === sourceId,
			).length;
		});

		return { upserted, deleted };
	} catch (err: any) {
		// Handle 410 Gone — syncToken expired, need full re-sync
		if (err.status === 410) {
			_syncServer!.changeDoc<CalendarDoc>(docId, 'gcal clear sync token', (d) => {
				d.sources[sourceId].syncToken = null;
				d.sources[sourceId].lastSyncStatus = 'error';
				d.sources[sourceId].lastSyncError = 'Sync token expired, will do full re-sync';
			});
			// Retry without sync token
			return syncGoogleSource(space, sourceId);
		}

		_syncServer!.changeDoc<CalendarDoc>(docId, 'gcal sync error', (d) => {
			d.sources[sourceId].lastSyncStatus = 'error';
			d.sources[sourceId].lastSyncError = err.message || String(err);
		});
		return { upserted: 0, deleted: 0, error: err.message || String(err) };
	}
}

// GET /api/google/calendars — list user's Google Calendars for selection
routes.get("/api/google/calendars", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;

	const googleToken = await getValidGoogleToken(dataSpace, _syncServer!);
	if (!googleToken) return c.json({ error: "Google not connected. Authorize at /api/oauth/google/authorize?space=" + dataSpace + "&returnTo=rcal" }, 400);

	try {
		const calendars = await listGoogleCalendars(googleToken);
		// Also check which ones are already subscribed
		const doc = ensureDoc(dataSpace);
		const subscribedIds = new Set(
			Object.values(doc.sources)
				.filter((s) => s.sourceType === 'GOOGLE')
				.map((s) => s.externalId),
		);

		return c.json(calendars.map((cal) => ({
			id: cal.id,
			name: cal.summary,
			description: cal.description || null,
			color: cal.backgroundColor || null,
			primary: cal.primary || false,
			accessRole: cal.accessRole,
			timeZone: cal.timeZone || null,
			subscribed: subscribedIds.has(cal.id),
		})));
	} catch (err: any) {
		return c.json({ error: `Failed to list calendars: ${err.message}` }, 502);
	}
});

// POST /api/google/subscribe — subscribe to a Google Calendar
routes.post("/api/google/subscribe", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const body = await c.req.json();
	const { calendarId, name, color } = body;

	if (!calendarId) return c.json({ error: "calendarId is required" }, 400);

	const docId = calendarDocId(dataSpace);
	ensureDoc(dataSpace);

	// Check if already subscribed
	const doc = _syncServer!.getDoc<CalendarDoc>(docId)!;
	const existing = Object.values(doc.sources).find(
		(s) => s.sourceType === 'GOOGLE' && s.externalId === calendarId,
	);
	if (existing) return c.json({ error: "Already subscribed to this calendar", sourceId: existing.id }, 409);

	const sourceId = crypto.randomUUID();
	const now = Date.now();

	_syncServer!.changeDoc<CalendarDoc>(docId, `subscribe Google Calendar: ${name || calendarId}`, (d) => {
		d.sources[sourceId] = {
			id: sourceId,
			name: name || calendarId,
			sourceType: 'GOOGLE',
			url: null,
			color: color || '#4285f4',
			isActive: true,
			isVisible: true,
			syncIntervalMinutes: 10,
			lastSyncedAt: 0,
			ownerId: null,
			createdAt: now,
			externalId: calendarId,
			syncToken: null,
			lastSyncStatus: null,
			lastSyncError: null,
			eventCount: null,
		};
	});

	// Trigger initial sync
	const syncResult = await syncGoogleSource(dataSpace, sourceId);

	return c.json({ sourceId, ...syncResult }, 201);
});

// POST /api/google/sync/:sourceId — manual sync trigger for one source
routes.post("/api/google/sync/:sourceId", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const sourceId = c.req.param("sourceId");

	const result = await syncGoogleSource(dataSpace, sourceId);
	if (result.error) return c.json(result, 400);
	return c.json(result);
});

// POST /api/google/sync-all — sync all Google sources in this space
routes.post("/api/google/sync-all", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;

	const doc = ensureDoc(dataSpace);
	const googleSources = Object.values(doc.sources).filter(
		(s) => s.sourceType === 'GOOGLE' && s.isActive,
	);

	const results: Record<string, { upserted: number; deleted: number; error?: string }> = {};
	for (const source of googleSources) {
		results[source.id] = await syncGoogleSource(dataSpace, source.id);
	}

	return c.json({ synced: googleSources.length, results });
});

// DELETE /api/google/:sourceId — unsubscribe and remove all events from this source
routes.delete("/api/google/:sourceId", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const sourceId = c.req.param("sourceId");

	const docId = calendarDocId(dataSpace);
	const doc = ensureDoc(dataSpace);

	const source = doc.sources[sourceId];
	if (!source) return c.json({ error: "Source not found" }, 404);
	if (source.sourceType !== 'GOOGLE') return c.json({ error: "Not a Google Calendar source" }, 400);

	// Count events to delete
	const eventIds = Object.keys(doc.events).filter((k) => doc.events[k].sourceId === sourceId);

	_syncServer!.changeDoc<CalendarDoc>(docId, `unsubscribe Google Calendar: ${source.name}`, (d) => {
		// Remove all events from this source
		for (const eid of eventIds) {
			delete d.events[eid];
		}
		// Remove the source
		delete d.sources[sourceId];
	});

	return c.json({ ok: true, eventsRemoved: eventIds.length });
});

// POST /api/google/push/:eventId — export a single rCal event to Google Calendar
routes.post("/api/google/push/:eventId", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Authentication required" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const eventId = c.req.param("eventId");
	const body = await c.req.json().catch(() => ({})) as { targetCalendarId?: string };

	const doc = ensureDoc(dataSpace);
	const event = doc.events[eventId];
	if (!event) return c.json({ error: "Event not found" }, 404);

	// Don't push Google events back to Google
	if (event.rToolSource === 'GOOGLE_CAL') {
		return c.json({ error: "Cannot push a Google Calendar event back to Google" }, 400);
	}

	const googleToken = await getValidGoogleToken(dataSpace, _syncServer!);
	if (!googleToken) return c.json({ error: "Google not connected" }, 400);

	// Use specified target or default to primary calendar
	let targetCalId = body.targetCalendarId;
	if (!targetCalId) {
		const calendars = await listGoogleCalendars(googleToken);
		const primary = calendars.find((cal) => cal.primary);
		targetCalId = primary?.id || calendars[0]?.id;
		if (!targetCalId) return c.json({ error: "No Google Calendars found" }, 400);
	}

	try {
		const gInput = mapCalendarEventToGoogle(event);
		const googleEventId = await createGoogleEvent(googleToken, targetCalId, gInput);
		return c.json({ ok: true, googleEventId, calendarId: targetCalId });
	} catch (err: any) {
		return c.json({ error: `Failed to create Google event: ${err.message}` }, 502);
	}
});

// ── Background sync loop ──

const GCAL_SYNC_INTERVAL_MS = 10 * 60 * 1000; // 10 minutes
let _gcalSyncTimer: ReturnType<typeof setInterval> | null = null;

async function runGcalBackgroundSync() {
	if (!_syncServer) return;
	const docIds = _syncServer.listDocs().filter((id: string) => id.endsWith(':cal:events'));

	for (const docId of docIds) {
		const space = docId.split(':')[0];
		const doc = _syncServer.getDoc<CalendarDoc>(docId);
		if (!doc) continue;

		const googleSources = Object.values(doc.sources).filter(
			(s) => s.sourceType === 'GOOGLE' && s.isActive && s.externalId,
		);

		for (const source of googleSources) {
			try {
				await syncGoogleSource(space, source.id);
			} catch (err) {
				console.error(`[rCal] Background sync failed for ${space}/${source.name}:`, err);
			}
		}
	}
}

function startGcalSyncLoop() {
	if (_gcalSyncTimer) return;
	// Initial sync after 30s startup delay
	setTimeout(() => {
		runGcalBackgroundSync().catch((err) => console.error('[rCal] Background sync error:', err));
	}, 30_000);
	// Then every 10 minutes
	_gcalSyncTimer = setInterval(() => {
		runGcalBackgroundSync().catch((err) => console.error('[rCal] Background sync error:', err));
	}, GCAL_SYNC_INTERVAL_MS);
}

// ── Page route ──
routes.get("/", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	return c.html(renderShell({
		title: `${space} — Calendar | rSpace`,
		moduleId: "rcal",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
		scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js?v=5"></script>`,
		styles: `<link rel="stylesheet" href="/modules/rcal/cal.css">
			<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`,
	}));
});

// ── MI Integration ──

/** Lean event shape for MI system prompt (minimal token footprint) */
export interface MICalendarEvent {
	title: string;
	start: string;    // ISO date string
	end: string;      // ISO date string
	allDay: boolean;
	location: string | null;
	isVirtual: boolean;
	tags: string[] | null;
}

/**
 * Read upcoming events directly from Automerge (no HTTP overhead).
 * Returns [] if sync server not initialized or no events exist.
 */
export function getUpcomingEventsForMI(space: string, days = 14, limit = 5): MICalendarEvent[] {
	if (!_syncServer) return [];
	const docId = calendarDocId(space);
	const doc = _syncServer.getDoc<CalendarDoc>(docId);
	if (!doc) return [];

	const now = Date.now();
	const futureMs = now + days * 86400000;

	return Object.values(doc.events)
		.filter((e) => e.startTime >= now && e.startTime <= futureMs)
		.sort((a, b) => a.startTime - b.startTime)
		.slice(0, limit)
		.map((e) => ({
			title: e.title,
			start: new Date(e.startTime).toISOString(),
			end: e.endTime ? new Date(e.endTime).toISOString() : new Date(e.startTime).toISOString(),
			allDay: e.allDay,
			location: e.locationName || null,
			isVirtual: e.isVirtual,
			tags: e.tags ? Array.from(e.tags) : null,
		}));
}

/**
 * Migrate existing events: add `tags` field where missing.
 * Automerge docs created before the tags schema update won't have the key.
 * This runs on every startup — it's a no-op if tags already exist.
 */
function migrateTagsField(space: string) {
	if (!_syncServer) return;
	const docId = calendarDocId(space);
	const doc = _syncServer.getDoc<CalendarDoc>(docId);
	if (!doc) return;

	const needsMigration = Object.values(doc.events).some((e) => !('tags' in e));
	if (!needsMigration) return;

	// Tag assignment for known demo events
	const demoTags: Record<string, string[]> = {
		"rSpace Launch Party": ["launch", "community"],
		"Provider Onboarding Workshop": ["onboarding", "cosmolocal"],
		"Weekly Community Standup": ["recurring", "community"],
		"Sprint: Module Seeding & Polish": ["sprint", "dev"],
		"rFlows Budget Review": ["governance", "finance"],
		"Cosmolocal Design Sprint": ["sprint", "design", "travel"],
		"Q1 Retrospective": ["recurring", "community", "retrospective"],
	};

	_syncServer.changeDoc<CalendarDoc>(docId, 'migrate: add tags field', (d) => {
		for (const key of Object.keys(d.events)) {
			const ev = d.events[key] as any;
			if (!('tags' in ev)) {
				ev.tags = demoTags[ev.title] || null;
			}
		}
	});
	console.log(`[Cal] Migrated tags field for ${space}`);
}

export const calModule: RSpaceModule = {
	id: "rcal",
	name: "rCal",
	icon: "📅",
	description: "Temporal coordination calendar with lunar, solar, and seasonal systems",
	canvasShapes: ["folk-calendar"],
	scoping: { defaultScope: 'global', userConfigurable: true },
	docSchemas: [{ pattern: '{space}:cal:events', description: 'Calendar events and sources', init: calendarSchema.init }],
	routes,
	standaloneDomain: "rcal.online",
	landingPage: renderLanding,
	seedTemplate: seedDemoIfEmpty,
	async onInit(ctx) {
		_syncServer = ctx.syncServer;
		// Seed demo data for the default space
		seedDemoIfEmpty("demo");

		// Migrate tags field for all spaces that have cal docs (runs after loadAllDocs via setTimeout)
		setTimeout(() => {
			for (const docId of _syncServer!.listDocs()) {
				if (docId.endsWith(':cal:events')) {
					const space = docId.split(':')[0];
					migrateTagsField(space);
				}
			}
		}, 5000); // Run after loadAllDocs completes

		// Start Google Calendar background sync loop
		startGcalSyncLoop();
	},
	feeds: [
		{
			id: "events",
			name: "Events",
			kind: "data",
			description: "Calendar events with times, locations, and virtual meeting links",
			filterable: true,
		},
		{
			id: "availability",
			name: "Availability",
			kind: "data",
			description: "Free/busy windows and scheduling availability across calendars",
		},
	],
	acceptsFeeds: ["data", "governance"],
	outputPaths: [
		{ path: "saved-views", name: "Saved Views", icon: "👁️", description: "Custom calendar views and filters" },
		{ path: "events", name: "Events", icon: "📅", description: "Calendar events across all systems" },
	],
	onboardingActions: [
		{ label: "Connect Google Calendar", icon: "🔄", description: "Sync events from your Google Calendar automatically", type: 'link', href: '/api/oauth/google/authorize?space={space}&returnTo=rcal' },
		{ label: "Import Calendar (.ics)", icon: "📅", description: "Upload an ICS file from Google Calendar, Outlook, or Apple", type: 'upload', upload: { accept: '.ics,.ical', endpoint: '/{space}/rcal/api/import-ics' } },
		{ label: "Subscribe to iCal URL", icon: "🔗", description: "Add a read-only calendar feed", type: 'link', href: '/{space}/rcal?action=add-source' },
		{ label: "Create an Event", icon: "✏️", description: "Add your first calendar event", type: 'create', href: '/{space}/rcal' },
	],
};
