/**
 * Docs module — notebooks, rich-text notes, voice transcription, import/export.
 *
 * Port of rnotes-online (Next.js + Prisma → Hono + postgres.js).
 * Supports multiple note types: text, code, bookmark, audio, image, file.
 *
 * Local-first: All data stored exclusively in Automerge documents via SyncServer.
 */

import { Hono } from "hono";
import * as Automerge from "@automerge/automerge";
import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule, SpaceLifecycleContext } from "../../shared/module";
import { resolveDataSpace } from "../../shared/scope-resolver";
import { verifyToken, extractToken } from "../../server/auth";
import { resolveCallerRole } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces";
import { filterArrayByVisibility } from "../../shared/membrane";
import { renderLanding } from "./landing";
import { notebookSchema, notebookDocId, connectionsDocId, createNoteItem } from "./schemas";
import type { NotebookDoc, NoteItem, ConnectionsDoc } from "./schemas";
import { getConverter, getAllConverters, hashContent } from "./converters/index";
import { importFile } from "./converters/file-import";
import { syncNotionNote, syncGoogleDocsNote, syncFileBasedNotes } from "./converters/sync";
import type { ConvertedNote } from "./converters/index";
import type { SyncServer } from "../../server/local-first/sync-server";
import { unlockArticle } from "../../lib/article-unlock";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";

const routes = new Hono();

// ── SyncServer ref (set during onInit) ──
let _syncServer: SyncServer | null = null;

// ── Automerge helpers ──

/** Lazily ensure a notebook doc exists for a given space + notebookId. */
function ensureDoc(space: string, notebookId: string): NotebookDoc {
	const docId = notebookDocId(space, notebookId);
	let doc = _syncServer!.getDoc<NotebookDoc>(docId);
	if (!doc) {
		doc = Automerge.change(Automerge.init<NotebookDoc>(), 'init', (d) => {
			const init = notebookSchema.init();
			Object.assign(d, init);
			d.meta.spaceSlug = space;
			d.notebook.id = notebookId;
		});
		_syncServer!.setDoc(docId, doc);
	}
	return doc;
}

/** Generate a URL-safe slug from a title. */
function slugify(title: string): string {
	return title
		.toLowerCase()
		.replace(/[^a-z0-9]+/g, "-")
		.replace(/^-|-$/g, "")
		.slice(0, 80) || "untitled";
}

/** Generate a compact unique ID (timestamp + random suffix). */
function newId(): string {
	return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}

// ── Automerge ↔ REST conversion helpers ──

/** List all notebook docs for a space from the SyncServer. */
function listNotebooks(space: string): { docId: string; doc: NotebookDoc }[] {
	if (!_syncServer) return [];
	const results: { docId: string; doc: NotebookDoc }[] = [];
	const prefix = `${space}:notes:notebooks:`;
	for (const docId of _syncServer.listDocs()) {
		if (docId.startsWith(prefix)) {
			const doc = _syncServer.getDoc<NotebookDoc>(docId);
			if (doc && doc.notebook && doc.notebook.title) results.push({ docId, doc });
		}
	}
	return results;
}

/** Convert an Automerge NotebookDoc to REST API format. */
function notebookToRest(doc: NotebookDoc) {
	const nb = doc.notebook;
	return {
		id: nb.id,
		title: nb.title,
		slug: nb.slug,
		description: nb.description,
		cover_color: nb.coverColor,
		is_public: nb.isPublic,
		note_count: String(Object.keys(doc.items).length),
		created_at: new Date(nb.createdAt).toISOString(),
		updated_at: new Date(nb.updatedAt).toISOString(),
	};
}

/** Convert an Automerge NoteItem to REST API format. */
function noteToRest(item: NoteItem) {
	return {
		id: item.id,
		notebook_id: item.notebookId,
		title: item.title,
		content: item.content,
		content_plain: item.contentPlain,
		content_format: item.contentFormat || undefined,
		type: item.type,
		tags: item.tags.length > 0 ? item.tags : null,
		is_pinned: item.isPinned,
		sort_order: item.sortOrder,
		url: item.url,
		language: item.language,
		file_url: item.fileUrl,
		mime_type: item.mimeType,
		file_size: item.fileSize,
		duration: item.duration,
		source_ref: item.sourceRef ? {
			source: item.sourceRef.source,
			syncStatus: item.sourceRef.syncStatus,
			lastSyncedAt: item.sourceRef.lastSyncedAt,
		} : undefined,
		created_at: new Date(item.createdAt).toISOString(),
		updated_at: new Date(item.updatedAt).toISOString(),
	};
}

/** Find the notebook doc that contains a given note ID. */
function findNote(space: string, noteId: string): { docId: string; doc: NotebookDoc; item: NoteItem } | null {
	for (const { docId, doc } of listNotebooks(space)) {
		const item = doc.items[noteId];
		if (item) return { docId, doc, item };
	}
	return null;
}

// ── Seed demo data into Automerge (runs once if no notebooks exist) ──

function seedDemoIfEmpty(space: string) {
	if (!_syncServer) return;

	// Resolve effective data space (global for rdocs by default)
	const dataSpace = resolveDataSpace("rdocs", space);

	// If the space already has notebooks, skip (or was already seeded)
	const _connectionsDoc = _syncServer!.getDoc<ConnectionsDoc>(connectionsDocId(dataSpace));
	if ((_connectionsDoc?.meta as any)?.seeded || listNotebooks(dataSpace).length > 0) return;

	const now = Date.now();

	// Notebook 1: Project Ideas
	const nb1Id = newId();
	const nb1DocId = notebookDocId(dataSpace, nb1Id);
	const nb1Doc = Automerge.change(Automerge.init<NotebookDoc>(), "Seed: Project Ideas", (d) => {
		d.meta = { module: "notes", collection: "notebooks", version: 1, spaceSlug: dataSpace, createdAt: now };
		d.notebook = { id: nb1Id, title: "Project Ideas", slug: "project-ideas", description: "Brainstorms and design notes for the r* ecosystem", coverColor: "#6366f1", isPublic: true, createdAt: now, updatedAt: now };
		d.items = {};
	});
	_syncServer.setDoc(nb1DocId, nb1Doc);

	// Notebook 2: Meeting Notes
	const nb2Id = newId();
	const nb2DocId = notebookDocId(dataSpace, nb2Id);
	const nb2Doc = Automerge.change(Automerge.init<NotebookDoc>(), "Seed: Meeting Notes", (d) => {
		d.meta = { module: "notes", collection: "notebooks", version: 1, spaceSlug: dataSpace, createdAt: now };
		d.notebook = { id: nb2Id, title: "Meeting Notes", slug: "meeting-notes", description: "Weekly standups, design reviews, and retrospectives", coverColor: "#f59e0b", isPublic: true, createdAt: now, updatedAt: now };
		d.items = {};
	});
	_syncServer.setDoc(nb2DocId, nb2Doc);

	// Notebook 3: How-To Guides
	const nb3Id = newId();
	const nb3DocId = notebookDocId(dataSpace, nb3Id);
	const nb3Doc = Automerge.change(Automerge.init<NotebookDoc>(), "Seed: How-To Guides", (d) => {
		d.meta = { module: "notes", collection: "notebooks", version: 1, spaceSlug: dataSpace, createdAt: now };
		d.notebook = { id: nb3Id, title: "How-To Guides", slug: "how-to-guides", description: "Tutorials and onboarding guides for contributors", coverColor: "#10b981", isPublic: true, createdAt: now, updatedAt: now };
		d.items = {};
	});
	_syncServer.setDoc(nb3DocId, nb3Doc);

	// Seed notes into notebooks
	const notes = [
		{
			nbId: nb1Id, nbDocId: nb1DocId, title: "Cosmolocal Manufacturing Network",
			content: "## Vision\n\nDesign global, manufacture local. Every creative work should be producible by the nearest capable provider.\n\n## Key Components\n\n- **Artifact Spec**: Standardized envelope describing what to produce\n- **Provider Registry**: Directory of local makers with capabilities + pricing\n- **rCart**: Marketplace connecting creators to providers\n- **Revenue Splits**: 50% provider, 35% creator, 15% community\n\n## Open Questions\n\n- How do we handle quality assurance across distributed providers?\n- Should providers be able to set custom margins?\n- What's the minimum viable set of capabilities for launch?",
			tags: ["cosmolocal", "architecture"], pinned: true,
		},
		{
			nbId: nb1Id, nbDocId: nb1DocId, title: "Revenue Sharing Model",
			content: "## Current Split\n\n| Recipient | Share | Rationale |\n|-----------|-------|-----------|\n| Provider | 50% | Covers materials, labor, shipping |\n| Creator | 35% | Design and creative work |\n| Community | 15% | Platform maintenance, commons fund |\n\n## Enoughness Thresholds\n\nOnce a funnel reaches its sufficient threshold, surplus flows to the next highest-need funnel. This prevents accumulation and keeps resources flowing.\n\n## Implementation\n\nrFlows Flow Service handles deposits from rCart. Each order total is routed through the configured flow → funnel → overflow splits.",
			tags: ["cosmolocal", "governance"],
		},
		{
			nbId: nb1Id, nbDocId: nb1DocId, title: "FUN Model: Forget, Update, New",
			content: "## Replacing CRUD\n\nNothing is permanently destroyed in rSpace.\n\n- **Forget** replaces Delete — soft-delete with `forgotten: true`. Shapes stay in document, hidden from canvas. Memory panel lets you browse + Remember.\n- **Update** stays the same — public `sync.updateShape()` for programmatic updates\n- **New** replaces Create — language shift: toolbar says \"New X\", events are `new-shape`\n\n## Why?\n\nData sovereignty means users should always be able to recover their work. The Memory panel makes forgotten shapes discoverable, like a digital archive.",
			tags: ["design", "architecture"],
		},
		{
			nbId: nb2Id, nbDocId: nb2DocId, title: "Weekly Standup — Feb 15, 2026",
			content: "## Attendees\n\nAlice, Bob, Carol\n\n## Updates\n\n**Alice**: Finished EncryptID guardian recovery flow. 2-of-3 guardian approval working. Next: device linking via QR code.\n\n**Bob**: Provider registry now has 6 printers globally. Working on proximity search with earthdistance extension.\n\n**Carol**: rFlows river visualization deployed. Enoughness layer showing golden glow on sufficient funnels.\n\n## Action Items\n\n- [ ] Alice: Document guardian recovery API endpoints\n- [ ] Bob: Add turnaround time estimates to provider matching\n- [ ] Carol: Add demo mode to river view with mock data",
			tags: ["standup"],
		},
		{
			nbId: nb2Id, nbDocId: nb2DocId, title: "Design Review — rBooks Flipbook Reader",
			content: "## What We Reviewed\n\nThe react-pageflip integration for PDF reading in rBooks.\n\n## Feedback\n\n1. **Page turn animation** — smooth, feels good on desktop. On mobile, swipe gesture needs larger hit area.\n2. **PDF rendering** — react-pdf handles most PDFs well. Large files (>50MB) cause browser memory issues.\n3. **Read Locally mode** — IndexedDB storage works. Need to show storage usage somewhere.\n\n## Decisions\n\n- Ship current version, iterate on mobile\n- Add a 50MB soft warning on upload\n- Explore PDF.js worker for background rendering",
			tags: ["review", "design"],
		},
		{
			nbId: nb3Id, nbDocId: nb3DocId, title: "Getting Started with rSpace Development",
			content: "## Prerequisites\n\n- Bun runtime (v1.3+)\n- Docker + Docker Compose\n- Git access to Gitea\n\n## Local Setup\n\n```bash\ngit clone ssh://git@gitea.jeffemmett.com:223/jeffemmett/rspace-online.git\ncd rspace-online\nbun install\nbun run dev\n```\n\n## Module Structure\n\nEach module lives in `modules/{name}/` and exports an `RSpaceModule` interface:\n\n```typescript\nexport interface RSpaceModule {\n  id: string;\n  name: string;\n  icon: string;\n  description: string;\n  routes: Hono;\n}\n```\n\n## Adding a New Module\n\n1. Create `modules/{name}/mod.ts`\n2. Create `modules/{name}/components/` for web components\n3. Add build step in `vite.config.ts`\n4. Register in `server/index.ts`",
			tags: ["onboarding"],
		},
		{
			nbId: nb3Id, nbDocId: nb3DocId, title: "How to Add a Cosmolocal Provider",
			content: "## Overview\n\nProviders are local print shops, makerspaces, or studios that can fulfill rCart orders.\n\n## Steps\n\n1. Visit `providers.mycofi.earth`\n2. Sign in with your rStack passkey\n3. Click \"Register Provider\"\n4. Fill in:\n   - Name, location (address + coordinates)\n   - Capabilities (laser-print, risograph, screen-print, etc.)\n   - Substrates (paper types, fabric, vinyl)\n   - Turnaround time and pricing\n5. Submit for review\n\n## Matching Algorithm\n\nWhen an order comes in, rCart matches based on:\n- Required capabilities vs. provider capabilities\n- Geographic distance (earthdistance extension)\n- Turnaround time\n- Price",
			tags: ["cosmolocal", "onboarding"],
		},
	];

	for (const n of notes) {
		const noteId = newId();
		const contentPlain = n.content.replace(/<[^>]*>/g, " ").replace(/[#*|`\-\[\]]/g, " ").replace(/\s+/g, " ").trim();
		const item = createNoteItem(noteId, n.nbId, n.title, {
			content: n.content,
			contentPlain,
			tags: n.tags,
			isPinned: n.pinned || false,
		});

		_syncServer!.changeDoc<NotebookDoc>(n.nbDocId, `Seed note: ${n.title}`, (d) => {
			d.items[noteId] = item;
		});
	}

	// Mark this space as seeded so deletions don't trigger re-seeding
	const _connDocId = connectionsDocId(dataSpace);
	if (!_syncServer!.getDoc<ConnectionsDoc>(_connDocId)) {
		_syncServer!.setDoc(_connDocId, Automerge.change(Automerge.init<ConnectionsDoc>(), 'init connections', (d) => {
			d.meta = { module: 'notes', collection: 'connections', version: 1, spaceSlug: dataSpace, createdAt: Date.now() };
		}));
	}
	_syncServer!.changeDoc<ConnectionsDoc>(_connDocId, 'mark seeded', (d) => {
		if (d.meta) (d.meta as any).seeded = true;
	});

	console.log("[Docs] Demo data seeded: 3 notebooks, 7 notes");
}

// ── Content extraction helpers ──

/** Recursively extract plain text from a Tiptap JSON node tree. */
function walkTiptapNodes(node: any): string {
	if (node.text) return node.text;
	if (!node.content) return '';
	return node.content.map(walkTiptapNodes).join(node.type === 'paragraph' ? '\n' : '');
}

/** Extract plain text from content, handling both HTML and tiptap-json formats. */
function extractPlainText(content: string, format?: string): string {
	if (format === 'tiptap-json') {
		try {
			const doc = JSON.parse(content);
			return walkTiptapNodes(doc).trim();
		} catch { return ''; }
	}
	// Legacy HTML stripping
	return content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}

// ── Notebooks API ──

// GET /api/notebooks — list notebooks
routes.get("/api/notebooks", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;

	// 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 {}
	}

	const notebooks = listNotebooks(dataSpace).map(({ doc }) => notebookToRest(doc));
	notebooks.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
	return c.json({ notebooks, source: "automerge" });
});

// POST /api/notebooks — create notebook
routes.post("/api/notebooks", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	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 body = await c.req.json();
	const { title, description, cover_color } = body;

	const nbTitle = title || "Untitled Notebook";
	const notebookId = newId();
	const now = Date.now();

	const doc = ensureDoc(dataSpace, notebookId);
	_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, notebookId), "Create notebook", (d) => {
		d.notebook.id = notebookId;
		d.notebook.title = nbTitle;
		d.notebook.slug = slugify(nbTitle);
		d.notebook.description = description || "";
		d.notebook.coverColor = cover_color || "#3b82f6";
		d.notebook.isPublic = false;
		d.notebook.createdAt = now;
		d.notebook.updatedAt = now;
	});

	const updatedDoc = _syncServer!.getDoc<NotebookDoc>(notebookDocId(dataSpace, notebookId))!;
	return c.json(notebookToRest(updatedDoc), 201);
});

// GET /api/notebooks/:id — notebook detail with notes
routes.get("/api/notebooks/:id", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");

	const docId = notebookDocId(dataSpace, id);
	const doc = _syncServer?.getDoc<NotebookDoc>(docId);
	if (!doc || !doc.notebook || !doc.notebook.title) {
		return c.json({ error: "Notebook not found" }, 404);
	}

	const nb = notebookToRest(doc);
	const notes = Object.values(doc.items)
		.map(noteToRest)
		.sort((a, b) => {
			if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1;
			return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
		});
	return c.json({ ...nb, notes, source: "automerge" });
});

// PUT /api/notebooks/:id — update notebook
routes.put("/api/notebooks/:id", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	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 id = c.req.param("id");
	const body = await c.req.json();
	const { title, description, cover_color, is_public } = body;

	if (title === undefined && description === undefined && cover_color === undefined && is_public === undefined) {
		return c.json({ error: "No fields to update" }, 400);
	}

	const docId = notebookDocId(dataSpace, id);
	const doc = _syncServer?.getDoc<NotebookDoc>(docId);
	if (!doc || !doc.notebook || !doc.notebook.title) {
		return c.json({ error: "Notebook not found" }, 404);
	}

	_syncServer!.changeDoc<NotebookDoc>(docId, "Update notebook", (d) => {
		if (title !== undefined) d.notebook.title = title;
		if (description !== undefined) d.notebook.description = description;
		if (cover_color !== undefined) d.notebook.coverColor = cover_color;
		if (is_public !== undefined) d.notebook.isPublic = is_public;
		d.notebook.updatedAt = Date.now();
	});

	const updatedDoc = _syncServer!.getDoc<NotebookDoc>(docId)!;
	return c.json(notebookToRest(updatedDoc));
});

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

	const docId = notebookDocId(dataSpace, id);
	const doc = _syncServer?.getDoc<NotebookDoc>(docId);
	if (!doc || !doc.notebook || !doc.notebook.title) {
		return c.json({ error: "Notebook not found" }, 404);
	}

	// Clear all items and blank the notebook title to mark as deleted.
	// SyncServer has no removeDoc API, so we empty the doc instead.
	_syncServer!.changeDoc<NotebookDoc>(docId, "Delete notebook", (d) => {
		for (const key of Object.keys(d.items)) {
			delete d.items[key];
		}
		d.notebook.title = "";
		d.notebook.updatedAt = Date.now();
	});

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

// ── Notes API ──

// GET /api/notes — list all notes
routes.get("/api/notes", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const { notebook_id, type, q, limit = "50", offset = "0" } = c.req.query();

	let allNotes: ReturnType<typeof noteToRest>[] = [];
	const notebooks = notebook_id
		? (() => {
			const doc = _syncServer?.getDoc<NotebookDoc>(notebookDocId(dataSpace, notebook_id));
			return doc ? [{ doc }] : [];
		})()
		: listNotebooks(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 {}
	}

	for (const { doc } of notebooks) {
		const visibleItems = filterArrayByVisibility(Object.values(doc.items), callerRole);
		for (const item of visibleItems) {
			if (type && item.type !== type) continue;
			if (q) {
				const lower = q.toLowerCase();
				if (!item.title.toLowerCase().includes(lower) && !item.contentPlain.toLowerCase().includes(lower)) continue;
			}
			allNotes.push(noteToRest(item));
		}
	}

	// Sort: pinned first, then by updated_at desc
	allNotes.sort((a, b) => {
		if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1;
		return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime();
	});

	const lim = Math.min(parseInt(limit), 100);
	const off = parseInt(offset) || 0;
	return c.json({ notes: allNotes.slice(off, off + lim), source: "automerge" });
});

// POST /api/notes — create note
routes.post("/api/notes", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	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 body = await c.req.json();
	const { notebook_id, title, content, content_format, type, url, language, file_url, mime_type, file_size, duration, tags } = body;

	if (!title?.trim()) return c.json({ error: "Title is required" }, 400);
	if (!notebook_id) return c.json({ error: "notebook_id is required" }, 400);

	const contentPlain = content ? extractPlainText(content, content_format) : "";

	// Normalize tags
	const tagNames: string[] = [];
	if (tags && Array.isArray(tags)) {
		for (const tagName of tags) {
			const name = (tagName as string).trim().toLowerCase();
			if (name) tagNames.push(name);
		}
	}

	const noteId = newId();
	const item = createNoteItem(noteId, notebook_id, title.trim(), {
		authorId: claims.sub ?? null,
		content: content || "",
		contentPlain,
		type: type || "NOTE",
		url: url || null,
		language: language || null,
		fileUrl: file_url || null,
		mimeType: mime_type || null,
		fileSize: file_size || null,
		duration: duration || null,
		tags: tagNames,
	});

	// Ensure the notebook doc exists, then add the note
	ensureDoc(dataSpace, notebook_id);
	const docId = notebookDocId(dataSpace, notebook_id);
	_syncServer!.changeDoc<NotebookDoc>(docId, `Create note: ${title.trim()}`, (d) => {
		d.items[noteId] = item;
		d.notebook.updatedAt = Date.now();
	});

	// Notify space members about the new note
	import('../rinbox/agent-notify').then(({ sendSpaceNotification }) => {
		sendSpaceNotification(dataSpace, `New Note: ${title.trim()}`,
			`<h3>${title.trim()}</h3>${contentPlain ? `<p>${contentPlain.slice(0, 200)}${contentPlain.length > 200 ? '...' : ''}</p>` : ''}<p><a href="https://rspace.online/${dataSpace}/rdocs">View in rDocs</a></p>`
		).catch(() => {});
	}).catch(() => {});

	return c.json(noteToRest(item), 201);
});

// GET /api/notes/:id — note detail
routes.get("/api/notes/:id", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const id = c.req.param("id");

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

	return c.json({ ...noteToRest(found.item), source: "automerge" });
});

// PUT /api/notes/:id — update note
routes.put("/api/notes/: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 { title, content, content_format, type, url, language, is_pinned, sort_order } = body;

	if (title === undefined && content === undefined && type === undefined &&
		url === undefined && language === undefined && is_pinned === undefined && sort_order === undefined) {
		return c.json({ error: "No fields to update" }, 400);
	}

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

	const contentPlain = content !== undefined
		? extractPlainText(content, content_format || found.item.contentFormat)
		: undefined;

	_syncServer!.changeDoc<NotebookDoc>(found.docId, `Update note ${id}`, (d) => {
		const item = d.items[id];
		if (!item) return;
		if (title !== undefined) item.title = title;
		if (content !== undefined) item.content = content;
		if (contentPlain !== undefined) item.contentPlain = contentPlain;
		if (content_format !== undefined) (item as any).contentFormat = content_format;
		if (type !== undefined) item.type = type;
		if (url !== undefined) item.url = url;
		if (language !== undefined) item.language = language;
		if (is_pinned !== undefined) item.isPinned = is_pinned;
		if (sort_order !== undefined) item.sortOrder = sort_order;
		item.updatedAt = Date.now();
	});

	// Return the updated note
	const updatedDoc = _syncServer!.getDoc<NotebookDoc>(found.docId)!;
	const updatedItem = updatedDoc.items[id];
	return c.json(noteToRest(updatedItem));
});

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

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

	_syncServer!.changeDoc<NotebookDoc>(found.docId, `Delete note ${id}`, (d) => {
		delete d.items[id];
		d.notebook.updatedAt = Date.now();
	});

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

// ── Import/Export API ──

/** Helper: import ConvertedNotes into a notebook. */
async function saveAttachments(attachments: { filename: string; data: Uint8Array; mimeType: string }[]) {
	if (attachments.length === 0) return;
	const uploadDir = '/data/files/uploads';
	await mkdir(uploadDir, { recursive: true });
	for (const att of attachments) {
		await writeFile(join(uploadDir, att.filename), att.data);
	}
}

function importNotesIntoNotebook(
	space: string,
	notebookId: string,
	convertedNotes: ConvertedNote[],
): { imported: number; updated: number } {
	let imported = 0;
	let updated = 0;

	// Save any attachments to disk
	for (const cn of convertedNotes) {
		if (cn.attachments && cn.attachments.length > 0) {
			saveAttachments(cn.attachments).catch(() => {}); // fire-and-forget
		}
	}

	ensureDoc(space, notebookId);
	const docId = notebookDocId(space, notebookId);

	_syncServer!.changeDoc<NotebookDoc>(docId, `Import ${convertedNotes.length} notes`, (d) => {
		for (const cn of convertedNotes) {
			// Check if a note with the same sourceRef already exists (re-import)
			let existingId: string | null = null;
			if (cn.sourceRef) {
				for (const [id, item] of Object.entries(d.items)) {
					if (item.sourceRef?.source === cn.sourceRef.source &&
						item.sourceRef?.externalId === cn.sourceRef.externalId) {
						existingId = id;
						break;
					}
				}
			}

			if (existingId) {
				// Update existing note
				const item = d.items[existingId];
				item.title = cn.title;
				item.content = cn.content;
				item.contentPlain = cn.contentPlain;
				item.contentFormat = 'tiptap-json';
				item.tags = cn.tags;
				item.sourceRef = cn.sourceRef;
				item.updatedAt = Date.now();
				updated++;
			} else {
				// Create new note
				const noteId = newId();
				d.items[noteId] = {
					...createNoteItem(noteId, notebookId, cn.title, {
						content: cn.content,
						contentPlain: cn.contentPlain,
						contentFormat: 'tiptap-json',
						tags: cn.tags,
						type: cn.type || 'NOTE',
						sourceRef: cn.sourceRef,
					}),
				};
				imported++;
			}
		}
		d.notebook.updatedAt = Date.now();
	});

	return { imported, updated };
}

/** Get connection tokens for a space. */
function getConnectionDoc(space: string): ConnectionsDoc | null {
	const docId = connectionsDocId(space);
	return _syncServer?.getDoc<ConnectionsDoc>(docId) || null;
}

// POST /api/import/upload — ZIP upload for Logseq/Obsidian
routes.post("/api/import/upload", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	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 formData = await c.req.formData();
	const file = formData.get("file") as File | null;
	const source = formData.get("source") as string;
	const notebookId = formData.get("notebookId") as string | null;

	if (!file) return c.json({ error: "No file uploaded" }, 400);
	if (!source || !['logseq', 'obsidian', 'evernote', 'roam'].includes(source)) {
		return c.json({ error: "source must be 'logseq', 'obsidian', 'evernote', or 'roam'" }, 400);
	}

	const converter = getConverter(source);
	if (!converter) return c.json({ error: `Unknown converter: ${source}` }, 400);

	const fileData = new Uint8Array(await file.arrayBuffer());
	const result = await converter.import({ fileData });

	// Determine target notebook
	let targetNotebookId = notebookId;
	if (!targetNotebookId) {
		// Create a new notebook with the import title
		targetNotebookId = newId();
		const now = Date.now();
		ensureDoc(dataSpace, targetNotebookId);
		_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, targetNotebookId), "Create import notebook", (d) => {
			d.notebook.id = targetNotebookId!;
			d.notebook.title = result.notebookTitle;
			d.notebook.slug = slugify(result.notebookTitle);
			d.notebook.createdAt = now;
			d.notebook.updatedAt = now;
		});
	}

	const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, result.notes);

	return c.json({
		ok: true,
		notebookId: targetNotebookId,
		notebookTitle: result.notebookTitle,
		imported,
		updated,
		warnings: result.warnings,
	});
});

// POST /api/import/files — Generic file import (md, txt, html, images, etc.)
routes.post("/api/import/files", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	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 formData = await c.req.formData();
	const notebookId = formData.get("notebookId") as string | null;
	const files: any[] = [];
	for (const [key, value] of formData.entries()) {
		if (key === 'files' && typeof value === 'object' && value !== null && 'arrayBuffer' in value) files.push(value);
	}
	if (files.length === 0) return c.json({ error: "No files uploaded" }, 400);

	const convertedNotes: ConvertedNote[] = [];
	const warnings: string[] = [];

	for (const file of files) {
		try {
			const data = new Uint8Array(await file.arrayBuffer());
			const note = await importFile(file.name, data, file.type || undefined);
			convertedNotes.push(note);
		} catch (err) {
			warnings.push(`Failed to import ${file.name}: ${(err as Error).message}`);
		}
	}

	if (convertedNotes.length === 0) {
		return c.json({ error: "No files could be imported", warnings }, 400);
	}

	let targetNotebookId = notebookId;
	if (!targetNotebookId) {
		targetNotebookId = newId();
		const now = Date.now();
		ensureDoc(dataSpace, targetNotebookId);
		_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, targetNotebookId), "Create file import notebook", (d) => {
			d.notebook.id = targetNotebookId!;
			d.notebook.title = files.length === 1 ? files[0].name.replace(/\.[^.]+$/, '') : 'File Import';
			d.notebook.slug = slugify(d.notebook.title);
			d.notebook.createdAt = now;
			d.notebook.updatedAt = now;
		});
	}

	const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, convertedNotes);

	return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings });
});

// POST /api/import/notion — Import selected Notion pages
routes.post("/api/import/notion", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	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 body = await c.req.json();
	const { pageIds, notebookId, recursive } = body;

	if (!pageIds || !Array.isArray(pageIds) || pageIds.length === 0) {
		return c.json({ error: "pageIds array is required" }, 400);
	}

	const conn = getConnectionDoc(dataSpace);
	if (!conn?.notion?.accessToken) {
		return c.json({ error: "Notion not connected. Connect your Notion account first." }, 400);
	}

	const converter = getConverter('notion')!;
	const result = await converter.import({
		pageIds,
		recursive: recursive || false,
		accessToken: conn.notion.accessToken,
	});

	let targetNotebookId = notebookId;
	if (!targetNotebookId) {
		targetNotebookId = newId();
		const now = Date.now();
		ensureDoc(dataSpace, targetNotebookId);
		_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, targetNotebookId), "Create Notion import notebook", (d) => {
			d.notebook.id = targetNotebookId!;
			d.notebook.title = result.notebookTitle;
			d.notebook.slug = slugify(result.notebookTitle);
			d.notebook.createdAt = now;
			d.notebook.updatedAt = now;
		});
	}

	const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, result.notes);

	return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings: result.warnings });
});

// POST /api/import/google-docs — Import selected Google Docs
routes.post("/api/import/google-docs", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	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 body = await c.req.json();
	const { docIds, notebookId } = body;

	if (!docIds || !Array.isArray(docIds) || docIds.length === 0) {
		return c.json({ error: "docIds array is required" }, 400);
	}

	const conn = getConnectionDoc(dataSpace);
	if (!conn?.google?.accessToken) {
		return c.json({ error: "Google not connected. Connect your Google account first." }, 400);
	}

	const converter = getConverter('google-docs')!;
	const result = await converter.import({
		pageIds: docIds,
		accessToken: conn.google.accessToken,
	});

	let targetNotebookId = notebookId;
	if (!targetNotebookId) {
		targetNotebookId = newId();
		const now = Date.now();
		ensureDoc(dataSpace, targetNotebookId);
		_syncServer!.changeDoc<NotebookDoc>(notebookDocId(dataSpace, targetNotebookId), "Create Google Docs import notebook", (d) => {
			d.notebook.id = targetNotebookId!;
			d.notebook.title = result.notebookTitle;
			d.notebook.slug = slugify(result.notebookTitle);
			d.notebook.createdAt = now;
			d.notebook.updatedAt = now;
		});
	}

	const { imported, updated } = importNotesIntoNotebook(dataSpace, targetNotebookId, result.notes);

	return c.json({ ok: true, notebookId: targetNotebookId, imported, updated, warnings: result.warnings });
});

// GET /api/import/notion/pages — Browse Notion pages for selection
routes.get("/api/import/notion/pages", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const conn = getConnectionDoc(dataSpace);
	if (!conn?.notion?.accessToken) {
		return c.json({ error: "Notion not connected" }, 400);
	}

	try {
		const res = await fetch("https://api.notion.com/v1/search", {
			method: "POST",
			headers: {
				"Authorization": `Bearer ${conn.notion.accessToken}`,
				"Notion-Version": "2022-06-28",
				"Content-Type": "application/json",
			},
			body: JSON.stringify({
				filter: { property: "object", value: "page" },
				sort: { direction: "descending", timestamp: "last_edited_time" },
				page_size: 50,
			}),
		});
		if (!res.ok) return c.json({ error: "Failed to fetch Notion pages" }, 502);

		const data = await res.json() as any;
		const pages = (data.results || []).map((p: any) => {
			const titleProp = p.properties?.title || p.properties?.Name;
			const title = titleProp?.title?.[0]?.plain_text || "Untitled";
			return {
				id: p.id,
				title,
				lastEdited: p.last_edited_time,
				icon: p.icon?.emoji || null,
			};
		});

		return c.json({ pages });
	} catch (err) {
		return c.json({ error: (err as Error).message }, 500);
	}
});

// GET /api/import/google-docs/list — Browse Google Docs for selection
routes.get("/api/import/google-docs/list", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const conn = getConnectionDoc(dataSpace);
	if (!conn?.google?.accessToken) {
		return c.json({ error: "Google not connected" }, 400);
	}

	try {
		const res = await fetch(
			"https://www.googleapis.com/drive/v3/files?q=mimeType='application/vnd.google-apps.document'&orderBy=modifiedTime desc&pageSize=50&fields=files(id,name,modifiedTime)",
			{
				headers: { "Authorization": `Bearer ${conn.google.accessToken}` },
			}
		);
		if (!res.ok) return c.json({ error: "Failed to fetch Google Docs" }, 502);

		const data = await res.json() as any;
		const docs = (data.files || []).map((f: any) => ({
			id: f.id,
			title: f.name,
			lastModified: f.modifiedTime,
		}));

		return c.json({ docs });
	} catch (err) {
		return c.json({ error: (err as Error).message }, 500);
	}
});

// GET /api/export/obsidian — Download Obsidian-format ZIP
routes.get("/api/export/obsidian", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const notebookId = c.req.query("notebookId");
	if (!notebookId) return c.json({ error: "notebookId is required" }, 400);

	const docId = notebookDocId(dataSpace, notebookId);
	const doc = _syncServer?.getDoc<NotebookDoc>(docId);
	if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404);

	const notes = Object.values(doc.items);
	const converter = getConverter('obsidian')!;
	const result = await converter.export(notes, { notebookTitle: doc.notebook.title });

	return new Response(result.data as unknown as BodyInit, {
		headers: {
			"Content-Type": result.mimeType,
			"Content-Disposition": `attachment; filename="${result.filename}"`,
		},
	});
});

// GET /api/export/logseq — Download Logseq-format ZIP
routes.get("/api/export/logseq", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const notebookId = c.req.query("notebookId");
	if (!notebookId) return c.json({ error: "notebookId is required" }, 400);

	const docId = notebookDocId(dataSpace, notebookId);
	const doc = _syncServer?.getDoc<NotebookDoc>(docId);
	if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404);

	const notes = Object.values(doc.items);
	const converter = getConverter('logseq')!;
	const result = await converter.export(notes, { notebookTitle: doc.notebook.title });

	return new Response(result.data as unknown as BodyInit, {
		headers: {
			"Content-Type": result.mimeType,
			"Content-Disposition": `attachment; filename="${result.filename}"`,
		},
	});
});

// GET /api/export/markdown — Download universal Markdown ZIP
routes.get("/api/export/markdown", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const notebookId = c.req.query("notebookId");
	const noteIds = c.req.query("noteIds");

	let notes: NoteItem[] = [];
	let title = "rDocs Export";

	if (notebookId) {
		const docId = notebookDocId(dataSpace, notebookId);
		const doc = _syncServer?.getDoc<NotebookDoc>(docId);
		if (!doc || !doc.notebook?.title) return c.json({ error: "Notebook not found" }, 404);
		notes = Object.values(doc.items);
		title = doc.notebook.title;
	} else if (noteIds) {
		const ids = noteIds.split(",").map(id => id.trim());
		for (const id of ids) {
			const found = findNote(dataSpace, id);
			if (found) notes.push(found.item);
		}
	} else {
		return c.json({ error: "notebookId or noteIds is required" }, 400);
	}

	// Use obsidian converter for generic markdown export (it produces clean markdown + YAML frontmatter)
	const converter = getConverter('obsidian')!;
	const result = await converter.export(notes, { notebookTitle: title });

	// Rename the file
	const filename = `${title.replace(/\s+/g, '-').toLowerCase()}-markdown.zip`;
	return new Response(result.data as unknown as BodyInit, {
		headers: {
			"Content-Type": "application/zip",
			"Content-Disposition": `attachment; filename="${filename}"`,
		},
	});
});

// POST /api/export/notion — Push notes to Notion
routes.post("/api/export/notion", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	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 body = await c.req.json();
	const { notebookId, noteIds, parentId } = body;

	const conn = getConnectionDoc(dataSpace);
	if (!conn?.notion?.accessToken) {
		return c.json({ error: "Notion not connected" }, 400);
	}

	let notes: NoteItem[] = [];
	if (notebookId) {
		const docId = notebookDocId(dataSpace, notebookId);
		const doc = _syncServer?.getDoc<NotebookDoc>(docId);
		if (doc) notes = Object.values(doc.items);
	} else if (noteIds && Array.isArray(noteIds)) {
		for (const id of noteIds) {
			const found = findNote(dataSpace, id);
			if (found) notes.push(found.item);
		}
	}

	if (notes.length === 0) return c.json({ error: "No notes to export" }, 400);

	const converter = getConverter('notion')!;
	const result = await converter.export(notes, {
		accessToken: conn.notion.accessToken,
		parentId,
	});

	const resultData = JSON.parse(new TextDecoder().decode(result.data));
	return c.json(resultData);
});

// POST /api/export/google-docs — Push notes to Google Docs
routes.post("/api/export/google-docs", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	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 body = await c.req.json();
	const { notebookId, noteIds, parentId } = body;

	const conn = getConnectionDoc(dataSpace);
	if (!conn?.google?.accessToken) {
		return c.json({ error: "Google not connected" }, 400);
	}

	let notes: NoteItem[] = [];
	if (notebookId) {
		const docId = notebookDocId(dataSpace, notebookId);
		const doc = _syncServer?.getDoc<NotebookDoc>(docId);
		if (doc) notes = Object.values(doc.items);
	} else if (noteIds && Array.isArray(noteIds)) {
		for (const id of noteIds) {
			const found = findNote(dataSpace, id);
			if (found) notes.push(found.item);
		}
	}

	if (notes.length === 0) return c.json({ error: "No notes to export" }, 400);

	const converter = getConverter('google-docs')!;
	const result = await converter.export(notes, {
		accessToken: conn.google.accessToken,
		parentId,
	});

	const resultData = JSON.parse(new TextDecoder().decode(result.data));
	return c.json(resultData);
});

// ── Sync routes ──

// POST /api/sync/note/:noteId — Sync a single note (API sources)
routes.post("/api/sync/note/:noteId", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	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 noteId = c.req.param("noteId");
	const found = findNote(dataSpace, noteId);
	if (!found) return c.json({ error: "Note not found" }, 404);

	const note = found.item;
	if (!note.sourceRef) return c.json({ error: "Note has no source reference" }, 400);

	const conn = getConnectionDoc(dataSpace);
	let result;

	if (note.sourceRef.source === 'notion') {
		if (!conn?.notion?.accessToken) return c.json({ error: "Notion not connected" }, 400);
		result = await syncNotionNote(note, conn.notion.accessToken);
	} else if (note.sourceRef.source === 'google-docs') {
		if (!conn?.google?.accessToken) return c.json({ error: "Google not connected" }, 400);
		result = await syncGoogleDocsNote(note, conn.google.accessToken);
	} else {
		return c.json({ error: `Sync not supported for source: ${note.sourceRef.source}` }, 400);
	}

	// Apply sync result to Automerge doc
	if (result.action === 'updated' && result.updatedContent) {
		_syncServer!.changeDoc<NotebookDoc>(found.docId, `Sync update ${noteId}`, (d) => {
			const item = d.items[noteId];
			if (!item) return;
			item.content = result.updatedContent!;
			item.contentPlain = result.updatedPlain || '';
			item.contentFormat = 'tiptap-json';
			item.sourceRef!.contentHash = result.remoteHash || '';
			item.sourceRef!.lastSyncedAt = Date.now();
			item.sourceRef!.syncStatus = 'synced';
			item.updatedAt = Date.now();
		});
	} else if (result.action === 'conflict' && result.updatedContent) {
		_syncServer!.changeDoc<NotebookDoc>(found.docId, `Sync conflict ${noteId}`, (d) => {
			const item = d.items[noteId];
			if (!item) return;
			item.sourceRef!.syncStatus = 'conflict';
			item.conflictContent = result.updatedContent!;
		});
	}

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

// POST /api/sync/notebook/:id — Sync all notes with sourceRef in a notebook
routes.post("/api/sync/notebook/:id", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	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 notebookId = c.req.param("id");
	const docId = notebookDocId(dataSpace, notebookId);
	const doc = _syncServer?.getDoc<NotebookDoc>(docId);
	if (!doc) return c.json({ error: "Notebook not found" }, 404);

	const conn = getConnectionDoc(dataSpace);
	const results: Record<string, { action: string; error?: string }> = {};
	let synced = 0, conflicts = 0, errors = 0;

	for (const [noteId, note] of Object.entries(doc.items)) {
		if (!note.sourceRef) continue;

		let result;
		if (note.sourceRef.source === 'notion' && conn?.notion?.accessToken) {
			result = await syncNotionNote(note, conn.notion.accessToken);
		} else if (note.sourceRef.source === 'google-docs' && conn?.google?.accessToken) {
			result = await syncGoogleDocsNote(note, conn.google.accessToken);
		} else {
			continue; // Skip file-based sources (need ZIP re-upload)
		}

		if (result.action === 'updated' && result.updatedContent) {
			_syncServer!.changeDoc<NotebookDoc>(docId, `Sync update ${noteId}`, (d) => {
				const item = d.items[noteId];
				if (!item) return;
				item.content = result.updatedContent!;
				item.contentPlain = result.updatedPlain || '';
				item.contentFormat = 'tiptap-json';
				item.sourceRef!.contentHash = result.remoteHash || '';
				item.sourceRef!.lastSyncedAt = Date.now();
				item.sourceRef!.syncStatus = 'synced';
				item.updatedAt = Date.now();
			});
			synced++;
		} else if (result.action === 'conflict') {
			_syncServer!.changeDoc<NotebookDoc>(docId, `Sync conflict ${noteId}`, (d) => {
				const item = d.items[noteId];
				if (!item) return;
				item.sourceRef!.syncStatus = 'conflict';
				item.conflictContent = result.updatedContent || '';
			});
			conflicts++;
		} else if (result.action === 'error') {
			errors++;
		}

		results[noteId] = { action: result.action, error: result.error };
	}

	return c.json({ ok: true, notebookId, synced, conflicts, errors, results });
});

// POST /api/sync/upload — Re-upload vault ZIP for file-based sync (Obsidian/Logseq)
routes.post("/api/sync/upload", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	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 formData = await c.req.formData();
	const file = formData.get("file") as File | null;
	const notebookId = formData.get("notebookId") as string;
	const source = formData.get("source") as 'obsidian' | 'logseq';

	if (!file) return c.json({ error: "No file uploaded" }, 400);
	if (!notebookId) return c.json({ error: "notebookId required" }, 400);
	if (!source || !['obsidian', 'logseq'].includes(source)) {
		return c.json({ error: "source must be 'obsidian' or 'logseq'" }, 400);
	}

	const docId = notebookDocId(dataSpace, notebookId);
	const doc = _syncServer?.getDoc<NotebookDoc>(docId);
	if (!doc) return c.json({ error: "Notebook not found" }, 404);

	const notes = Object.values(doc.items).filter(n => n.sourceRef?.source === source);
	if (notes.length === 0) {
		return c.json({ error: `No ${source} notes in this notebook` }, 400);
	}

	const zipData = new Uint8Array(await file.arrayBuffer());
	const results = await syncFileBasedNotes(notes, zipData, source);

	let synced = 0, conflicts = 0;
	for (const [noteId, result] of results) {
		if (result.action === 'updated' && result.updatedContent) {
			_syncServer!.changeDoc<NotebookDoc>(docId, `Sync update ${noteId}`, (d) => {
				const item = d.items[noteId];
				if (!item) return;
				item.content = result.updatedContent!;
				item.contentPlain = result.updatedPlain || '';
				item.contentFormat = 'tiptap-json';
				item.sourceRef!.contentHash = result.remoteHash || '';
				item.sourceRef!.lastSyncedAt = Date.now();
				item.sourceRef!.syncStatus = 'synced';
				item.updatedAt = Date.now();
			});
			synced++;
		} else if (result.action === 'conflict' && result.updatedContent) {
			_syncServer!.changeDoc<NotebookDoc>(docId, `Sync conflict ${noteId}`, (d) => {
				const item = d.items[noteId];
				if (!item) return;
				item.sourceRef!.syncStatus = 'conflict';
				item.conflictContent = result.updatedContent || '';
			});
			conflicts++;
		}
	}

	return c.json({ ok: true, notebookId, synced, conflicts, total: notes.length });
});

// GET /api/sync/status/:id — Get sync status for a notebook's notes
routes.get("/api/sync/status/:id", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;

	const notebookId = c.req.param("id");
	const docId = notebookDocId(dataSpace, notebookId);
	const doc = _syncServer?.getDoc<NotebookDoc>(docId);
	if (!doc) return c.json({ error: "Notebook not found" }, 404);

	const statuses: Record<string, { source: string; syncStatus?: string; lastSyncedAt?: number; hasConflict: boolean }> = {};
	let syncable = 0;
	for (const [noteId, note] of Object.entries(doc.items)) {
		if (!note.sourceRef) continue;
		syncable++;
		statuses[noteId] = {
			source: note.sourceRef.source,
			syncStatus: note.sourceRef.syncStatus,
			lastSyncedAt: note.sourceRef.lastSyncedAt,
			hasConflict: note.sourceRef.syncStatus === 'conflict',
		};
	}

	return c.json({ notebookId, syncable, statuses });
});

// GET /api/connections — Status of all integrations
routes.get("/api/connections", async (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	const conn = getConnectionDoc(dataSpace);

	return c.json({
		notion: conn?.notion ? {
			connected: true,
			workspaceName: conn.notion.workspaceName,
			connectedAt: conn.notion.connectedAt,
		} : { connected: false },
		google: conn?.google ? {
			connected: true,
			email: conn.google.email,
			connectedAt: conn.google.connectedAt,
		} : { connected: false },
		logseq: { connected: true, note: "File-based, no account needed" },
		obsidian: { connected: true, note: "File-based, no account needed" },
		evernote: { connected: true, note: "File-based, no account needed" },
		roam: { connected: true, note: "File-based, no account needed" },
		files: { connected: true, note: "Direct file import" },
	});
});

// ── File uploads ──

import { existsSync, mkdirSync } from "fs";

const UPLOAD_DIR = "/data/files/generated";

// POST /api/uploads — Upload a file (audio, image, etc.)
routes.post("/api/uploads", 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 formData = await c.req.formData();
	const file = formData.get("file") as File | null;
	if (!file) return c.json({ error: "No file provided" }, 400);

	// Ensure upload dir exists
	if (!existsSync(UPLOAD_DIR)) mkdirSync(UPLOAD_DIR, { recursive: true });

	const ext = file.name.split('.').pop() || 'bin';
	const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`;
	const filepath = join(UPLOAD_DIR, filename);

	const arrayBuffer = await file.arrayBuffer();
	await Bun.write(filepath, arrayBuffer);

	return c.json({
		url: `/data/files/generated/${filename}`,
		mimeType: file.type || 'application/octet-stream',
		size: file.size,
		filename,
	}, 201);
});

// GET /api/uploads/:filename — Serve uploaded file
routes.get("/api/uploads/:filename", async (c) => {
	const filename = c.req.param("filename");
	// Path traversal protection
	if (filename.includes('..') || filename.includes('/')) {
		return c.json({ error: "Invalid filename" }, 400);
	}
	const filepath = join(UPLOAD_DIR, filename);
	const file = Bun.file(filepath);
	if (!(await file.exists())) return c.json({ error: "File not found" }, 404);
	return new Response(file.stream(), {
		headers: { "Content-Type": file.type || "application/octet-stream" },
	});
});

// ── Voice transcription proxy ──

// POST /api/voice/transcribe — Proxy to voice-command-api
routes.post("/api/voice/transcribe", 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); }

	try {
		const formData = await c.req.formData();
		const upstream = await fetch("http://voice-command-api:8000/api/voice/transcribe", {
			method: "POST",
			body: formData,
			signal: AbortSignal.timeout(60000),
		});
		if (!upstream.ok) return c.json({ error: "Transcription service error" }, 502);
		const result = await upstream.json();
		return c.json(result);
	} catch (err) {
		return c.json({ error: "Voice service unavailable" }, 502);
	}
});

// POST /api/voice/diarize — Proxy to voice-command-api (speaker diarization)
routes.post("/api/voice/diarize", 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); }

	try {
		const formData = await c.req.formData();
		const upstream = await fetch("http://voice-command-api:8000/api/voice/diarize", {
			method: "POST",
			body: formData,
			signal: AbortSignal.timeout(120000),
		});
		if (!upstream.ok) return c.json({ error: "Diarization service error" }, 502);
		const result = await upstream.json();
		return c.json(result);
	} catch (err) {
		return c.json({ error: "Voice service unavailable" }, 502);
	}
});

// ── Note summarization ──

const NOTEBOOK_API_URL = process.env.NOTEBOOK_API_URL || "http://open-notebook:5055";

// POST /api/notes/summarize — Quick summarization via Gemini/Ollama
routes.post("/api/notes/summarize", 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 { content, model = "gemini-flash", length = "medium" } = await c.req.json<{
		content: string; model?: string; length?: "short" | "medium" | "long";
	}>();
	if (!content?.trim()) return c.json({ error: "Content is required" }, 400);

	const lengthGuide: Record<string, string> = {
		short: "1-2 sentences",
		medium: "3-5 sentences (a short paragraph)",
		long: "2-3 paragraphs with key points as bullet points",
	};

	const systemPrompt = `You are a note summarizer. Summarize the following note content in ${lengthGuide[length] || lengthGuide.medium}. Be concise, capture the key ideas, and preserve any action items or decisions. Do not add commentary — return only the summary.`;

	try {
		// Use the internal /api/prompt endpoint
		const origin = new URL(c.req.url).origin;
		const res = await fetch(`${origin}/api/prompt`, {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({
				model,
				messages: [
					{ role: "user", content: `${systemPrompt}\n\n---\n\n${content}` },
				],
			}),
		});

		if (!res.ok) {
			const err = await res.json().catch(() => ({}));
			return c.json({ error: (err as any).error || "Summarization failed" }, 502);
		}

		const data = await res.json() as { content: string };
		return c.json({ summary: data.content, model });
	} catch (err) {
		console.error("[Notes] Summarize error:", err);
		return c.json({ error: "Summarization service unavailable" }, 502);
	}
});

// POST /api/notes/deep-summarize — RAG-enhanced multi-note analysis via open-notebook
routes.post("/api/notes/deep-summarize", 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 { noteIds, query } = await c.req.json<{ noteIds: string[]; query?: string }>();
	if (!noteIds?.length) return c.json({ error: "noteIds required" }, 400);

	// Gather note contents from Automerge docs
	const space = c.req.param("space") || "demo";
	const contents: { id: string; title: string; content: string }[] = [];

	if (_syncServer) {
		// Scan all notebook docs for matching note IDs
		const prefix = `${space}:notes:notebooks:`;
		for (const [docId, doc] of (_syncServer as any)._docs?.entries?.() || []) {
			if (typeof docId === 'string' && docId.startsWith(prefix)) {
				const nbDoc = doc as import("./schemas").NotebookDoc;
				if (!nbDoc?.items) continue;
				for (const nid of noteIds) {
					const item = nbDoc.items[nid];
					if (item) contents.push({ id: nid, title: item.title, content: item.contentPlain || item.content });
				}
			}
		}
	}

	if (contents.length === 0) return c.json({ error: "No matching notes found" }, 404);

	try {
		// Call open-notebook API for RAG-enhanced summary
		const res = await fetch(`${NOTEBOOK_API_URL}/api/v1/summarize`, {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({
				sources: contents.map(n => ({ title: n.title, content: n.content })),
				query: query || "Provide a comprehensive summary of these notes, highlighting key themes, decisions, and action items.",
			}),
			signal: AbortSignal.timeout(120000),
		});

		if (!res.ok) {
			console.error("[Notes] Deep summarize upstream error:", res.status);
			return c.json({ error: "Deep summarization service error" }, 502);
		}

		const result = await res.json() as { summary: string };
		return c.json({ summary: result.summary, sources: contents.map(n => n.id) });
	} catch (err) {
		console.error("[Notes] Deep summarize error:", err);
		return c.json({ error: "Deep summarization service unavailable" }, 502);
	}
});

// POST /api/notes/send-to-notebook — Send note content to open-notebook for RAG indexing
routes.post("/api/notes/send-to-notebook", 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 { noteId, title, content } = await c.req.json<{
		noteId: string; title: string; content: string;
	}>();
	if (!noteId || !content?.trim()) return c.json({ error: "noteId and content are required" }, 400);

	try {
		const formData = new FormData();
		formData.append("type", "text");
		formData.append("content", content);
		if (title) formData.append("title", title);
		formData.append("async_processing", "true");

		const res = await fetch(`${NOTEBOOK_API_URL}/api/sources`, {
			method: "POST",
			body: formData,
			signal: AbortSignal.timeout(30000),
		});

		if (!res.ok) {
			console.error("[Notes] Send to notebook upstream error:", res.status);
			return c.json({ error: "Open notebook service error" }, 502);
		}

		const result = await res.json() as { id: string; command_id?: string };
		return c.json({ sourceId: result.id, message: "Sent to open notebook for processing" });
	} catch (err) {
		console.error("[Notes] Send to notebook error:", err);
		return c.json({ error: "Open notebook service unavailable" }, 502);
	}
});

// POST /api/articles/unlock — Find archived version of a paywalled article
routes.post("/api/articles/unlock", async (c) => {
	const token = extractToken(c.req.raw.headers);
	if (!token) return c.json({ error: "Unauthorized" }, 401);
	try { await verifyToken(token); } catch { return c.json({ error: "Invalid token" }, 401); }

	const { url } = await c.req.json<{ url: string }>();
	if (!url) return c.json({ error: "Missing url" }, 400);

	try {
		const result = await unlockArticle(url);
		return c.json(result);
	} catch (err) {
		return c.json({ success: false, error: "Unlock failed" }, 500);
	}
});

// ── Extension download ──

// GET /extension/download — Serve the rDocs Chrome extension as a zip
routes.get("/extension/download", async (c) => {
	const JSZip = (await import("jszip")).default;
	const { readdir, readFile } = await import("fs/promises");
	const { join, resolve } = await import("path");

	const extDir = resolve(import.meta.dir, "browser-extension");
	const zip = new JSZip();

	async function addDir(dir: string, prefix: string) {
		const entries = await readdir(dir, { withFileTypes: true });
		for (const entry of entries) {
			const fullPath = join(dir, entry.name);
			const zipPath = prefix ? `${prefix}/${entry.name}` : entry.name;
			if (entry.isDirectory()) {
				await addDir(fullPath, zipPath);
			} else {
				const data = await readFile(fullPath);
				zip.file(zipPath, data);
			}
		}
	}

	try {
		await addDir(extDir, "");
		const buf = await zip.generateAsync({ type: "arraybuffer" });
		return new Response(buf, {
			headers: {
				"Content-Type": "application/zip",
				"Content-Disposition": 'attachment; filename="rdocs-extension.zip"',
			},
		});
	} catch (err) {
		console.error("[Notes] Extension zip error:", err);
		return c.json({ error: "Extension files not found" }, 404);
	}
});

// ── Page routes ──

// GET /voice — Standalone voice recorder page
routes.get("/voice", (c) => {
	const space = c.req.param("space") || "demo";
	return c.html(renderShell({
		title: `${space} — Voice Recorder | rSpace`,
		moduleId: "rdocs",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-voice-recorder space="${space}"></folk-voice-recorder>`,
		scripts: `<script type="module" src="/modules/rdocs/folk-voice-recorder.js?v=3"></script>`,
		styles: `<link rel="stylesheet" href="/modules/rdocs/notes.css?v=3">`,
	}));
});

// GET /transcripts — Transcription app (alias for voice recorder)
routes.get("/transcripts", (c) => {
	const space = c.req.param("space") || "demo";
	return c.html(renderShell({
		title: `${space} — Transcripts | rSpace`,
		moduleId: "rdocs",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-voice-recorder space="${space}"></folk-voice-recorder>`,
		scripts: `<script type="module" src="/modules/rdocs/folk-voice-recorder.js?v=3"></script>`,
		styles: `<link rel="stylesheet" href="/modules/rdocs/notes.css?v=3">`,
	}));
});

routes.get("/", (c) => {
	const space = c.req.param("space") || "demo";
	const dataSpace = c.get("effectiveSpace") || space;
	return c.html(renderShell({
		title: `${space} — Docs | rSpace`,
		moduleId: "rdocs",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-docs-app space="${space}"></folk-docs-app>`,
		scripts: `<script type="module" src="/modules/rdocs/folk-docs-app.js?v=1"></script>`,
		styles: `<link rel="stylesheet" href="/modules/rdocs/notes.css?v=1">`,
	}));
});

export const docsModule: RSpaceModule = {
	id: "rdocs",
	name: "rDocs",
	icon: "📄",
	description: "Rich-text notebooks, voice transcription, and import/export",
	scoping: { defaultScope: 'global', userConfigurable: true },
	routes,

	settingsSchema: [
		{
			key: 'defaultNotebookId',
			label: 'Default notebook for imports',
			type: 'notebook-id',
			description: 'Pre-selected notebook when importing from Logseq, Obsidian, or the web clipper',
		},
	],

	docSchemas: [
		{
			pattern: '{space}:notes:notebooks:{notebookId}',
			description: 'One Automerge doc per notebook, containing all notes as items',
			init: notebookSchema.init,
		},
	],

	seedTemplate: seedDemoIfEmpty,
	async onInit({ syncServer }) {
		_syncServer = syncServer;
		// Seeding happens after loadAllDocs via seedTemplate, not here
		console.log("[Docs] onInit complete (Automerge-only)");
	},

	async onSpaceCreate(ctx: SpaceLifecycleContext) {
		if (!_syncServer) return;

		// Create a default "My Notes" notebook doc, using resolved scope
		const dataSpace = resolveDataSpace("rdocs", ctx.spaceSlug);
		const notebookId = "default";
		const docId = notebookDocId(dataSpace, notebookId);

		if (_syncServer.getDoc(docId)) return; // already exists

		const doc = Automerge.init<NotebookDoc>();
		const initialized = Automerge.change(doc, "Create default notebook", (d) => {
			d.meta = {
				module: "notes",
				collection: "notebooks",
				version: 1,
				spaceSlug: dataSpace,
				createdAt: Date.now(),
			};
			d.notebook = {
				id: notebookId,
				title: "My Notes",
				slug: "my-notes",
				description: "Default notebook",
				coverColor: "#3b82f6",
				isPublic: false,
				createdAt: Date.now(),
				updatedAt: Date.now(),
			};
			d.items = {};
		});

		_syncServer.setDoc(docId, initialized);
		console.log(`[Docs] Created default notebook for space: ${ctx.spaceSlug}`);
	},

	landingPage: renderLanding,
	// standaloneDomain: not yet assigned for rDocs
	feeds: [
		{
			id: "notes-by-tag",
			name: "Notes by Tag",
			kind: "data",
			description: "Stream of notes filtered by tag (design, architecture, etc.)",
			emits: ["folk-markdown"],
			filterable: true,
		},
		{
			id: "recent-notes",
			name: "Recent Notes",
			kind: "data",
			description: "Latest notes across all notebooks",
			emits: ["folk-markdown"],
		},
	],
	acceptsFeeds: ["data", "resource"],
	outputPaths: [
		{ path: "notebooks", name: "Notebooks", icon: "📓", description: "Rich-text collaborative notebooks" },
		{ path: "transcripts", name: "Transcripts", icon: "🎙️", description: "Voice transcription records" },
		{ path: "articles", name: "Articles", icon: "📰", description: "Published articles and posts" },
	],
	onboardingActions: [
		{ label: "Import from Obsidian or Logseq", icon: "📂", description: "Import markdown files from your vault", type: 'link', href: '/{space}/rdocs?action=import' },
		{ label: "Import from Notion", icon: "📄", description: "Bring in pages from a Notion export", type: 'link', href: '/{space}/rdocs?action=import&source=notion' },
		{ label: "Create a Notebook", icon: "✏️", description: "Start writing from scratch", type: 'create', href: '/{space}/rdocs' },
	],
};

// ── MI Integration ──

export interface MINoteItem {
	id: string;
	title: string;
	contentPlain: string;
	tags: string[];
	type: string;
	updatedAt: number;
}

/**
 * Read recent docs directly from Automerge for the MI system prompt.
 */
export function getRecentDocsForMI(space: string, limit = 3): MINoteItem[] {
	if (!_syncServer) return [];
	const allNotes: MINoteItem[] = [];
	const prefix = `${space}:notes:notebooks:`;

	for (const docId of _syncServer.listDocs()) {
		if (!docId.startsWith(prefix)) continue;
		const doc = _syncServer.getDoc<NotebookDoc>(docId);
		if (!doc?.items) continue;

		for (const item of Object.values(doc.items)) {
			allNotes.push({
				id: item.id,
				title: item.title,
				contentPlain: (item.contentPlain || "").slice(0, 300),
				tags: item.tags ? Array.from(item.tags) : [],
				type: item.type,
				updatedAt: item.updatedAt,
			});
		}
	}

	return allNotes
		.sort((a, b) => b.updatedAt - a.updatedAt)
		.slice(0, limit);
}
