/**
 * Cart module — cosmolocal print-on-demand shop.
 *
 * Ported from /opt/apps/rcart/ (Express → Hono).
 * Handles catalog (artifact listings), orders, fulfillment resolution.
 * Integrates with provider-registry for provider matching and flow-service for revenue splits.
 *
 * Storage: Automerge documents via SyncServer (no PostgreSQL).
 */

import * as Automerge from "@automerge/automerge";
import { Hono } from "hono";
import { renderShell, buildSpaceUrl } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import { depositOrderRevenue } from "./flow";
import type { RSpaceModule } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth";
import { renderLanding } from "./landing";
import type { SyncServer } from '../../server/local-first/sync-server';
import {
	catalogSchema, orderSchema, shoppingCartSchema, shoppingCartIndexSchema,
	paymentRequestSchema, groupBuySchema,
	catalogDocId, orderDocId, shoppingCartDocId, shoppingCartIndexDocId,
	paymentRequestDocId, groupBuyDocId,
	type CatalogDoc, type CatalogEntry,
	type OrderDoc, type OrderMeta,
	type ShoppingCartDoc, type ShoppingCartIndexDoc,
	type PaymentRequestDoc, type PaymentRequestMeta,
	type GroupBuyDoc,
	type CartItem, type CartStatus,
} from './schemas';
import { extractProductFromUrl } from './extract';
import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak';
import { createMoonPayPaymentUrl, getMoonPayApiKey, getMoonPayEnv } from '../../shared/moonpay';

/** Tokens pegged 1:1 to USD — fiat amount can be inferred from crypto amount */
const USD_STABLECOINS = ['USDC', 'USDT', 'DAI', 'cUSDC'];
import QRCode from 'qrcode';
import { createTransport, type Transporter } from "nodemailer";
import {
	getRelayerAddress, checkAllowance, executeTransferFrom, buildApprovalCalldata,
} from './lib/recurring-executor';

let _syncServer: SyncServer | null = null;

// ── SMTP transport (lazy init) ──

let _smtpTransport: Transporter | null = null;

function getSmtpTransport(): Transporter | null {
	if (_smtpTransport) return _smtpTransport;
	const host = process.env.SMTP_HOST || "mail.rmail.online";
	const isInternal = host.includes('mailcow') || host.includes('postfix');
	if (!process.env.SMTP_PASS && !isInternal) return null;
	// Internal mailcow network: relay on port 25 without auth
	// External: use port 587 with STARTTLS + auth
	_smtpTransport = createTransport({
		host,
		port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587),
		secure: !isInternal && Number(process.env.SMTP_PORT) === 465,
		...(isInternal ? {} : {
			auth: {
				user: process.env.SMTP_USER || "noreply@rmail.online",
				pass: process.env.SMTP_PASS!,
			},
		}),
		tls: { rejectUnauthorized: false },
	});
	return _smtpTransport;
}

// ── EncryptID internal email lookup ──

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

async function lookupEncryptIDEmail(userId: string): Promise<{ email: string | null; username: string | null }> {
	try {
		const res = await fetch(`${ENCRYPTID_INTERNAL}/api/internal/user-email/${encodeURIComponent(userId)}`);
		if (!res.ok) return { email: null, username: null };
		return await res.json();
	} catch (e) {
		console.warn('[rcart] EncryptID email lookup failed:', e);
		return { email: null, username: null };
	}
}

const routes = new Hono();

// Provider registry URL (for fulfillment resolution)
const PROVIDER_REGISTRY_URL = process.env.PROVIDER_REGISTRY_URL || "";

function getProviderUrl(): string {
	// In unified mode, providers module is co-located — call its routes directly via internal URL
	// In standalone mode, use PROVIDER_REGISTRY_URL env
	return PROVIDER_REGISTRY_URL || "http://localhost:3000/demo/providers";
}

// ── Subscription interval helpers ──

const INTERVAL_MS: Record<string, number> = {
	weekly: 7 * 24 * 60 * 60 * 1000,
	biweekly: 14 * 24 * 60 * 60 * 1000,
	monthly: 30 * 24 * 60 * 60 * 1000,
	quarterly: 91 * 24 * 60 * 60 * 1000,
	yearly: 365 * 24 * 60 * 60 * 1000,
};

function computeNextDueDate(fromMs: number, interval: string): number {
	return fromMs + (INTERVAL_MS[interval] || INTERVAL_MS.monthly);
}

// ── Subscription reminder scheduler ──

let _reminderTimer: ReturnType<typeof setInterval> | null = null;

function startSubscriptionScheduler() {
	if (_reminderTimer) return;
	// Check every hour for due subscriptions
	_reminderTimer = setInterval(() => checkDueSubscriptions(), 60 * 60 * 1000);
	// Also run once on startup (after 30s delay for init)
	setTimeout(() => checkDueSubscriptions(), 30_000);
	console.log('[rcart] Subscription reminder scheduler started (hourly)');
}

async function checkDueSubscriptions() {
	if (!_syncServer) return;
	const now = Date.now();
	const transport = getSmtpTransport();

	// Scan all payment request docs for due subscriptions
	const allDocIds = _syncServer.listDocs?.() || [];
	for (const docId of allDocIds) {
		if (!docId.includes(':payment:')) continue;
		try {
			const doc = _syncServer.getDoc<PaymentRequestDoc>(docId);
			if (!doc) continue;
			const p = doc.payment;

			// Only process active subscriptions with a due date in the past
			if (p.status !== 'pending') continue;
			if (!p.interval || !p.nextDueAt) continue;
			if (p.nextDueAt > now) continue;

			// Don't process more than once per interval — check if last payment was recent
			const lastPayment = p.paymentHistory?.length > 0
				? p.paymentHistory[p.paymentHistory.length - 1]
				: null;
			const gracePeriodMs = Math.min(INTERVAL_MS[p.interval] * 0.5, 3 * 24 * 60 * 60 * 1000);
			if (lastPayment && (now - lastPayment.paidAt) < gracePeriodMs) continue;

			const space = doc.meta.spaceSlug || 'demo';
			const host = 'rspace.online';
			const payUrl = `https://${space}.${host}/rcart/pay/${p.id}`;
			const senderName = p.creatorUsername || 'Someone';
			const displayAmount = (!p.amount || p.amount === '0') ? 'a payment' : `${p.amount} ${p.token}`;

			// ── Attempt automated pull if payer has approved allowance ──
			if (p.payerIdentity && p.token !== 'ETH') {
				const usdcAddress = USDC_ADDRESSES[p.chainId];
				if (usdcAddress) {
					try {
						const decimals = p.token === 'USDC' ? 6 : 18;
						const txHash = await executeTransferFrom(
							usdcAddress,
							p.payerIdentity,
							p.recipientAddress,
							p.amount || '0',
							decimals,
							p.chainId,
						);

						// Record the automated payment
						_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'automated subscription payment', (d) => {
							d.payment.paidAt = now;
							d.payment.txHash = txHash;
							d.payment.paymentMethod = 'wallet';
							d.payment.paymentCount = (d.payment.paymentCount || 0) + 1;
							if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any;
							(d.payment.paymentHistory as any).push({
								txHash,
								transakOrderId: null,
								paymentMethod: 'wallet',
								payerIdentity: p.payerIdentity,
								payerEmail: p.subscriberEmail || null,
								amount: d.payment.amount,
								paidAt: now,
							});

							if (d.payment.maxPayments > 0 && d.payment.paymentCount >= d.payment.maxPayments) {
								d.payment.status = 'filled';
							} else {
								d.payment.status = 'pending';
								d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval!);
							}
							d.payment.updatedAt = now;
						});

						console.log(`[rcart] Auto-pulled subscription payment for ${p.id}: ${txHash}`);

						// Send receipt email
						if (transport && p.subscriberEmail) {
							const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online';
							transport.sendMail({
								from: `"${senderName} via rSpace" <${fromAddr}>`,
								to: p.subscriberEmail,
								subject: `Payment processed: ${displayAmount} to ${senderName}`,
								html: buildReceiptEmail(senderName, displayAmount, txHash, p.chainId, payUrl),
							}).catch(err => console.warn(`[rcart] Failed to send receipt for ${p.id}:`, err));
						}
						continue; // Payment executed — no reminder needed
					} catch (err) {
						console.warn(`[rcart] Auto-pull failed for ${p.id}:`, err);
						// Fall through to send reminder email instead
					}
				}
			}

			// ── Send reminder email (no auto-pull available or it failed) ──
			if (!transport || !p.subscriberEmail) continue;

			const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || 'noreply@rmail.online';
			const html = buildReminderEmail(senderName, displayAmount, p.interval!, p.paymentCount, p.maxPayments, payUrl);

			try {
				await transport.sendMail({
					from: `"${senderName} via rSpace" <${fromAddr}>`,
					to: p.subscriberEmail,
					subject: `Payment reminder: ${displayAmount} due to ${senderName}`,
					html,
				});
				console.log(`[rcart] Sent subscription reminder for ${p.id} to ${p.subscriberEmail}`);
			} catch (err) {
				console.warn(`[rcart] Failed to send reminder for ${p.id}:`, err);
			}
		} catch {
			// Skip docs that fail to parse
		}
	}
}

// ── Email template helpers ──

function buildReminderEmail(sender: string, amount: string, interval: string, count: number, maxPayments: number, payUrl: string): string {
	return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<tr><td style="background:linear-gradient(135deg,#f59e0b,#ef4444);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
  <h1 style="color:#fff;font-size:24px;margin:0">Payment Reminder</h1>
  <p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">Your ${interval} payment is due</p>
</td></tr>
<tr><td style="background:#1a1a24;padding:32px 24px">
  <p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
    Your recurring payment of <strong>${amount}</strong> to <strong>${sender}</strong> is due.
  </p>
  <p style="color:#94a3b8;font-size:14px;margin:0 0 24px">Payment ${count + 1}${maxPayments > 0 ? ` of ${maxPayments}` : ''}</p>
  <table width="100%" cellpadding="0" cellspacing="0">
    <tr><td align="center">
      <a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px">Pay Now</a>
    </td></tr>
  </table>
</td></tr>
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
  <p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
</td></tr>
</table></td></tr></table></body></html>`;
}

const CHAIN_EXPLORER_URLS: Record<number, string> = { 8453: 'https://basescan.org/tx/', 84532: 'https://sepolia.basescan.org/tx/', 1: 'https://etherscan.io/tx/' };

function buildReceiptEmail(sender: string, amount: string, txHash: string, chainId: number, payUrl: string): string {
	const explorer = CHAIN_EXPLORER_URLS[chainId];
	const txLink = explorer ? `<a href="${explorer}${txHash}" style="color:#67e8f9;text-decoration:none">${txHash.slice(0, 10)}...${txHash.slice(-8)}</a>` : txHash.slice(0, 18) + '...';
	return `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<tr><td style="background:linear-gradient(135deg,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
  <div style="font-size:48px;margin-bottom:8px">&#10003;</div>
  <h1 style="color:#fff;font-size:24px;margin:0">Subscription Payment Processed</h1>
</td></tr>
<tr><td style="background:#1a1a24;padding:32px 24px">
  <p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
    Your recurring payment of <strong>${amount}</strong> to <strong>${sender}</strong> has been automatically processed.
  </p>
  <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:16px">
    <tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Transaction</td>
        <td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right">${txLink}</td></tr>
  </table>
</td></tr>
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
  <p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View subscription</a></p>
</td></tr>
</table></td></tr></table></body></html>`;
}

// ── Automerge helpers ──

/** Lazily create (or retrieve) the catalog doc for a space. */
function ensureCatalogDoc(space: string): Automerge.Doc<CatalogDoc> {
	const docId = catalogDocId(space);
	let doc = _syncServer!.getDoc<CatalogDoc>(docId);
	if (!doc) {
		doc = Automerge.change(Automerge.init<CatalogDoc>(), 'init catalog', (d) => {
			const init = catalogSchema.init();
			Object.assign(d, init);
			d.meta.spaceSlug = space;
		});
		_syncServer!.setDoc(docId, doc);
	}
	return doc;
}

/** Get all order docs for a space by scanning known doc IDs. */
function getSpaceOrderDocs(space: string): Array<{ docId: string; doc: Automerge.Doc<OrderDoc> }> {
	const prefix = `${space}:cart:orders:`;
	const results: Array<{ docId: string; doc: Automerge.Doc<OrderDoc> }> = [];
	for (const id of _syncServer!.listDocs()) {
		if (id.startsWith(prefix)) {
			const doc = _syncServer!.getDoc<OrderDoc>(id);
			if (doc) results.push({ docId: id, doc });
		}
	}
	return results;
}

// ── CATALOG ROUTES ──

// POST /api/catalog/ingest — Add artifact to catalog
routes.post("/api/catalog/ingest", async (c) => {
	const space = c.req.param("space") || "demo";
	const artifact = await c.req.json();

	if (!artifact.id || !artifact.schema_version || !artifact.type) {
		return c.json({ error: "Invalid artifact envelope. Required: id, schema_version, type" }, 400);
	}
	if (artifact.type !== "print-ready") {
		return c.json({ error: `Only 'print-ready' artifacts can be listed. Got: '${artifact.type}'` }, 400);
	}
	if (!artifact.render_targets || Object.keys(artifact.render_targets).length === 0) {
		return c.json({ error: "print-ready artifacts must have at least one render_target" }, 400);
	}

	const doc = ensureCatalogDoc(space);

	// Check for duplicate artifact_id
	for (const [, entry] of Object.entries(doc.items)) {
		if (entry.artifactId === artifact.id) {
			return c.json({ error: "Artifact already listed", catalog_entry_id: entry.id }, 409);
		}
	}

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

	const docId = catalogDocId(space);
	_syncServer!.changeDoc<CatalogDoc>(docId, 'ingest catalog entry', (d) => {
		d.items[entryId] = {
			id: entryId,
			artifactId: artifact.id,
			artifact: artifact,
			title: artifact.payload?.title || "Untitled",
			productType: artifact.spec?.product_type || null,
			requiredCapabilities: artifact.spec?.required_capabilities || [],
			substrates: artifact.spec?.substrates || [],
			creatorId: artifact.creator?.id || null,
			sourceSpace: artifact.source_space || space,
			tags: artifact.payload?.tags || [],
			status: "active",
			createdAt: now,
			updatedAt: now,
		};
	});

	return c.json({
		id: entryId,
		artifact_id: artifact.id,
		title: artifact.payload?.title || "Untitled",
		product_type: artifact.spec?.product_type || null,
		status: "active",
		created_at: new Date(now).toISOString(),
	}, 201);
});

// GET /api/catalog — Browse catalog
routes.get("/api/catalog", async (c) => {
	const space = c.req.param("space") || "demo";
	const { product_type, capability, tag, source_space, q, limit = "50", offset = "0" } = c.req.query();

	const doc = ensureCatalogDoc(space);
	let entries = Object.values(doc.items);

	// Apply filters
	entries = entries.filter((e) => e.status === "active");
	if (product_type) entries = entries.filter((e) => e.productType === product_type);
	if (capability) {
		const caps = capability.split(",");
		entries = entries.filter((e) => caps.some((cap) => e.requiredCapabilities.includes(cap)));
	}
	if (tag) entries = entries.filter((e) => e.tags.includes(tag));
	if (source_space) entries = entries.filter((e) => e.sourceSpace === source_space);
	if (q) {
		const lower = q.toLowerCase();
		entries = entries.filter((e) => e.title.toLowerCase().includes(lower));
	}

	// Sort by createdAt descending
	entries.sort((a, b) => b.createdAt - a.createdAt);

	const limitNum = Math.min(parseInt(limit) || 50, 100);
	const offsetNum = parseInt(offset) || 0;
	const total = entries.length;
	const paged = entries.slice(offsetNum, offsetNum + limitNum);

	// Map to response shape matching the original SQL response
	const result = paged.map((e) => {
		const art = e.artifact as Record<string, any> | undefined;
		return {
			id: e.id,
			artifact_id: e.artifactId,
			title: e.title,
			product_type: e.productType,
			required_capabilities: e.requiredCapabilities,
			tags: e.tags,
			source_space: e.sourceSpace,
			description: art?.payload?.description || null,
			pricing: art?.pricing || null,
			dimensions: art?.spec?.dimensions || null,
			status: e.status,
			created_at: new Date(e.createdAt).toISOString(),
		};
	});

	return c.json({ entries: result, total, limit: limitNum, offset: offsetNum });
});

// GET /api/catalog/:id — Single catalog entry
routes.get("/api/catalog/:id", async (c) => {
	const space = c.req.param("space") || "demo";
	const id = c.req.param("id");
	const doc = ensureCatalogDoc(space);

	// Look up by entry id or artifact id
	let entry: CatalogEntry | undefined;
	if (doc.items[id]) {
		entry = doc.items[id];
	} else {
		entry = Object.values(doc.items).find((e) => e.artifactId === id);
	}

	if (!entry) return c.json({ error: "Catalog entry not found" }, 404);

	return c.json({
		id: entry.id,
		artifact: entry.artifact,
		status: entry.status,
		created_at: new Date(entry.createdAt).toISOString(),
		updated_at: new Date(entry.updatedAt).toISOString(),
	});
});

// PATCH /api/catalog/:id — Update listing status
routes.patch("/api/catalog/:id", async (c) => {
	const space = c.req.param("space") || "demo";
	const { status } = await c.req.json();
	const valid = ["active", "paused", "sold_out", "removed"];
	if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400);

	const doc = ensureCatalogDoc(space);
	const entryId = c.req.param("id");

	if (!doc.items[entryId]) return c.json({ error: "Catalog entry not found" }, 404);

	const docId = catalogDocId(space);
	_syncServer!.changeDoc<CatalogDoc>(docId, `update catalog status → ${status}`, (d) => {
		d.items[entryId].status = status;
		d.items[entryId].updatedAt = Date.now();
	});

	return c.json({ id: entryId, status });
});

// ── ORDER ROUTES ──

// POST /api/orders — Create an order
routes.post("/api/orders", async (c) => {
	const space = c.req.param("space") || "demo";

	// Optional auth — set buyer_did from claims if authenticated
	const token = extractToken(c.req.raw.headers);
	let buyerDid: string | null = null;
	if (token) {
		try { const claims = await verifyToken(token); buyerDid = claims.sub; } catch {}
	}

	const body = await c.req.json();
	const {
		catalog_entry_id, artifact_id, buyer_id, buyer_location, buyer_contact,
		provider_id, provider_name, provider_distance_km,
		quantity = 1, production_cost, creator_payout, community_payout,
		total_price, currency = "USD", payment_method = "manual",
		payment_tx, payment_network,
	} = body;

	if (!catalog_entry_id && !artifact_id) return c.json({ error: "Required: catalog_entry_id or artifact_id" }, 400);
	if (!provider_id || !total_price) return c.json({ error: "Required: provider_id, total_price" }, 400);

	// Look up catalog entry
	const catalogDoc = ensureCatalogDoc(space);
	const lookupId = catalog_entry_id || artifact_id;
	let entry: CatalogEntry | undefined;
	if (catalogDoc.items[lookupId]) {
		entry = catalogDoc.items[lookupId];
	} else {
		entry = Object.values(catalogDoc.items).find((e) => e.artifactId === lookupId || e.id === lookupId);
	}
	if (!entry) return c.json({ error: "Catalog entry not found" }, 404);

	// x402 detection
	const x402Header = c.req.header("x-payment");
	const effectiveMethod = x402Header ? "x402" : payment_method;
	const initialStatus = x402Header ? "paid" : "pending";

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

	// Create order doc
	const oDocId = orderDocId(space, orderId);
	let orderDoc = Automerge.change(Automerge.init<OrderDoc>(), 'create order', (d) => {
		const init = orderSchema.init();
		Object.assign(d, init);
		d.meta.spaceSlug = space;
		d.order.id = orderId;
		d.order.catalogEntryId = entry!.id;
		d.order.artifactId = entry!.artifactId;
		d.order.buyerId = buyerDid || buyer_id || null;
		d.order.buyerLocation = buyer_location ? JSON.stringify(buyer_location) : null;
		d.order.buyerContact = buyer_contact ? JSON.stringify(buyer_contact) : null;
		d.order.providerId = provider_id;
		d.order.providerName = provider_name || null;
		d.order.providerDistanceKm = provider_distance_km || null;
		d.order.quantity = quantity;
		d.order.productionCost = production_cost || null;
		d.order.creatorPayout = creator_payout || null;
		d.order.communityPayout = community_payout || null;
		d.order.totalPrice = total_price;
		d.order.currency = currency;
		d.order.status = initialStatus;
		d.order.paymentMethod = effectiveMethod;
		d.order.paymentTx = payment_tx || null;
		d.order.paymentNetwork = payment_network || null;
		d.order.createdAt = now;
		d.order.updatedAt = now;
		if (initialStatus === "paid") d.order.paidAt = now;
	});
	_syncServer!.setDoc(oDocId, orderDoc);

	const order = orderDoc.order;

	if (initialStatus === "paid") {
		depositOrderRevenue(total_price, orderId);
	}

	// Return response matching original shape
	return c.json(orderToResponse(order, entry), 201);
});

// GET /api/orders — List orders
routes.get("/api/orders", async (c) => {
	const space = c.req.param("space") || "demo";

	// Optional auth — filter by buyer if authenticated
	const token = extractToken(c.req.raw.headers);
	let authedBuyer: string | null = null;
	if (token) {
		try { const claims = await verifyToken(token); authedBuyer = claims.sub; } catch {}
	}

	const { status, provider_id, buyer_id, limit = "50", offset = "0" } = c.req.query();

	const orderDocs = getSpaceOrderDocs(space);

	// Build enriched order list with catalog info
	const catalogDoc = ensureCatalogDoc(space);

	let orders = orderDocs.map(({ doc }) => {
		const o = doc.order;
		const catEntry = catalogDoc.items[o.catalogEntryId];
		const resp = orderToResponse(o);
		resp.artifact_title = catEntry?.title || null;
		resp.product_type = catEntry?.productType || null;
		return resp;
	});

	// Apply filters
	if (status) orders = orders.filter((o) => o.status === status);
	if (provider_id) orders = orders.filter((o) => o.provider_id === provider_id);
	const effectiveBuyerId = buyer_id || (authedBuyer && !status && !provider_id ? authedBuyer : null);
	if (effectiveBuyerId) orders = orders.filter((o) => o.buyer_id === effectiveBuyerId);

	// Sort by created_at descending
	orders.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());

	const limitNum = Math.min(parseInt(limit) || 50, 100);
	const offsetNum = parseInt(offset) || 0;
	const paged = orders.slice(offsetNum, offsetNum + limitNum);

	return c.json({ orders: paged });
});

// GET /api/orders/:id — Single order
routes.get("/api/orders/:id", async (c) => {
	const space = c.req.param("space") || "demo";
	const orderId = c.req.param("id");
	const oDocId = orderDocId(space, orderId);
	const doc = _syncServer!.getDoc<OrderDoc>(oDocId);
	if (!doc) return c.json({ error: "Order not found" }, 404);

	const catalogDoc = ensureCatalogDoc(space);
	const catEntry = catalogDoc.items[doc.order.catalogEntryId];

	const resp = orderToResponse(doc.order);
	resp.artifact_envelope = catEntry?.artifact || null;
	resp.artifact_title = catEntry?.title || null;
	return c.json(resp);
});

// PATCH /api/orders/:id/status — Update order status
routes.patch("/api/orders/:id/status", async (c) => {
	const space = c.req.param("space") || "demo";
	const body = await c.req.json();
	const { status, payment_tx, payment_network } = body;
	const valid = ["pending", "paid", "accepted", "in_production", "ready", "shipped", "completed", "cancelled"];
	if (!valid.includes(status)) return c.json({ error: `status must be one of: ${valid.join(", ")}` }, 400);

	const orderId = c.req.param("id");
	const oDocId = orderDocId(space, orderId);
	const doc = _syncServer!.getDoc<OrderDoc>(oDocId);
	if (!doc) return c.json({ error: "Order not found" }, 404);

	const now = Date.now();

	const updated = _syncServer!.changeDoc<OrderDoc>(oDocId, `order status → ${status}`, (d) => {
		d.order.status = status;
		d.order.updatedAt = now;
		if (status === "paid") d.order.paidAt = now;
		if (status === "accepted") d.order.acceptedAt = now;
		if (status === "completed") d.order.completedAt = now;
		if (status === "paid" && payment_tx) {
			d.order.paymentTx = payment_tx;
			d.order.paymentNetwork = payment_network || null;
		}
	});

	if (!updated) return c.json({ error: "Order not found" }, 404);

	if (status === "paid" && updated.order.totalPrice) {
		depositOrderRevenue(updated.order.totalPrice, orderId);
	}

	return c.json(orderToResponse(updated.order));
});

// ── Response helpers ──

interface OrderResponse {
	id: string;
	catalog_entry_id: string;
	artifact_id: string;
	buyer_id: string | null;
	buyer_location: unknown;
	buyer_contact: unknown;
	provider_id: string | null;
	provider_name: string | null;
	provider_distance_km: number | null;
	quantity: number;
	production_cost: number | null;
	creator_payout: number | null;
	community_payout: number | null;
	total_price: number | null;
	currency: string;
	status: string;
	payment_method: string | null;
	payment_tx: string | null;
	payment_network: string | null;
	created_at: string;
	paid_at: string | null;
	accepted_at: string | null;
	completed_at: string | null;
	updated_at: string;
	artifact_title?: string | null;
	product_type?: string | null;
	artifact_envelope?: unknown;
}

/** Convert an OrderMeta to the flat response shape matching the original SQL rows. */
function orderToResponse(o: OrderMeta, catEntry?: CatalogEntry): OrderResponse {
	return {
		id: o.id,
		catalog_entry_id: o.catalogEntryId,
		artifact_id: o.artifactId,
		buyer_id: o.buyerId,
		buyer_location: o.buyerLocation ? tryParse(o.buyerLocation) : null,
		buyer_contact: o.buyerContact ? tryParse(o.buyerContact) : null,
		provider_id: o.providerId,
		provider_name: o.providerName,
		provider_distance_km: o.providerDistanceKm,
		quantity: o.quantity,
		production_cost: o.productionCost,
		creator_payout: o.creatorPayout,
		community_payout: o.communityPayout,
		total_price: o.totalPrice,
		currency: o.currency,
		status: o.status,
		payment_method: o.paymentMethod,
		payment_tx: o.paymentTx,
		payment_network: o.paymentNetwork,
		created_at: new Date(o.createdAt).toISOString(),
		paid_at: o.paidAt ? new Date(o.paidAt).toISOString() : null,
		accepted_at: o.acceptedAt ? new Date(o.acceptedAt).toISOString() : null,
		completed_at: o.completedAt ? new Date(o.completedAt).toISOString() : null,
		updated_at: new Date(o.updatedAt).toISOString(),
		...(catEntry ? { artifact_title: catEntry.title, product_type: catEntry.productType } : {}),
	};
}

function tryParse(s: string): unknown {
	try { return JSON.parse(s); } catch { return s; }
}

// ── FULFILLMENT ROUTES ──

function round2(n: number): number {
	return Math.round(n * 100) / 100;
}

interface ProviderMatch {
	id: string; name: string; distance_km: number;
	location: { city: string; region: string; country: string };
	turnaround: { standard_days: number; rush_days: number; rush_surcharge_pct: number };
	pricing: Record<string, { base_price: number; per_unit: number; per_unit_label: string; currency: string; volume_breaks?: { min_qty: number; per_unit: number }[] }>;
	wallet: string;
}

function composeCost(artifact: Record<string, unknown>, provider: ProviderMatch, quantity: number) {
	const spec = artifact.spec as Record<string, unknown> | undefined;
	const capabilities = (spec?.required_capabilities as string[]) || [];
	const pages = (spec?.pages as number) || 1;
	const breakdown: { label: string; amount: number }[] = [];

	for (const cap of capabilities) {
		const capPricing = provider.pricing?.[cap];
		if (!capPricing) continue;

		const basePrice = capPricing.base_price || 0;
		let perUnit = capPricing.per_unit || 0;
		const unitLabel = capPricing.per_unit_label || "per unit";

		if (capPricing.volume_breaks) {
			for (const vb of capPricing.volume_breaks) {
				if (quantity >= vb.min_qty) perUnit = vb.per_unit;
			}
		}

		let units = quantity;
		if (unitLabel.includes("page")) units = pages * quantity;

		if (basePrice > 0) breakdown.push({ label: `${cap} setup`, amount: round2(basePrice) });
		breakdown.push({ label: `${cap} (${units} x $${perUnit} ${unitLabel})`, amount: round2(perUnit * units) });
	}

	if (breakdown.length === 0) {
		breakdown.push({ label: "Production (estimated)", amount: round2(2.0 * quantity) });
	}

	return breakdown;
}

// POST /api/fulfill/resolve — Find fulfillment options
routes.post("/api/fulfill/resolve", async (c) => {
	const space = c.req.param("space") || "demo";
	const body = await c.req.json();
	const { artifact_id, catalog_entry_id, buyer_location, quantity = 1 } = body;

	if (!buyer_location?.lat || !buyer_location?.lng) {
		return c.json({ error: "Required: buyer_location.lat, buyer_location.lng" }, 400);
	}
	if (!artifact_id && !catalog_entry_id) {
		return c.json({ error: "Required: artifact_id or catalog_entry_id" }, 400);
	}

	const catalogDoc = ensureCatalogDoc(space);
	const lookupId = artifact_id || catalog_entry_id;

	// Find entry by id or artifact_id, must be active
	let entry: CatalogEntry | undefined;
	if (catalogDoc.items[lookupId] && catalogDoc.items[lookupId].status === "active") {
		entry = catalogDoc.items[lookupId];
	} else {
		entry = Object.values(catalogDoc.items).find(
			(e) => (e.artifactId === lookupId || e.id === lookupId) && e.status === "active"
		);
	}
	if (!entry) return c.json({ error: "Artifact not found in catalog" }, 404);

	const artifact = entry.artifact as Record<string, any>;
	const capabilities = artifact.spec?.required_capabilities || [];
	const substrates = artifact.spec?.substrates || [];

	if (capabilities.length === 0) {
		return c.json({ error: "Artifact has no required_capabilities" }, 400);
	}

	// Query provider registry (internal module or external service)
	const providerUrl = getProviderUrl();
	const params = new URLSearchParams({
		capabilities: capabilities.join(","),
		lat: String(buyer_location.lat),
		lng: String(buyer_location.lng),
	});
	if (substrates.length > 0) params.set("substrates", substrates.join(","));

	let providers: ProviderMatch[];
	try {
		const resp = await fetch(`${providerUrl}/api/providers/match?${params}`);
		if (!resp.ok) throw new Error(`Provider registry returned ${resp.status}`);
		const data = await resp.json() as { matches?: ProviderMatch[] };
		providers = data.matches || [];
	} catch (err) {
		return c.json({ error: "Failed to query provider registry", detail: err instanceof Error ? err.message : String(err) }, 502);
	}

	if (providers.length === 0) {
		return c.json({ options: [], message: "No local providers found", artifact_id: artifact.id });
	}

	const options = providers.map((provider) => {
		const costBreakdown = composeCost(artifact, provider, quantity);
		const productionCost = costBreakdown.reduce((sum, item) => sum + item.amount, 0);
		const pricing = artifact.pricing || {};
		const creatorPct = (pricing.creator_share_pct || 30) / 100;
		const communityPct = (pricing.community_share_pct || 0) / 100;
		const markupMultiplier = 1 / (1 - creatorPct - communityPct);
		const totalPrice = productionCost * markupMultiplier;
		const creatorPayout = totalPrice * creatorPct;
		const communityPayout = totalPrice * communityPct;

		return {
			provider: { id: provider.id, name: provider.name, distance_km: provider.distance_km, city: provider.location?.city || "Unknown" },
			production_cost: round2(productionCost),
			creator_payout: round2(creatorPayout),
			community_payout: round2(communityPayout),
			total_price: round2(totalPrice),
			currency: "USD",
			turnaround_days: provider.turnaround?.standard_days || 5,
			cost_breakdown: costBreakdown,
		};
	});

	options.sort((a, b) => a.total_price - b.total_price);

	return c.json({ artifact_id: artifact.id, artifact_title: artifact.payload?.title, buyer_location, quantity, options });
});

// ── SHOPPING CART helpers ──

/** Lazily create (or retrieve) the shopping cart index doc for a space. */
function ensureShoppingCartIndex(space: string): Automerge.Doc<ShoppingCartIndexDoc> {
	const docId = shoppingCartIndexDocId(space);
	let doc = _syncServer!.getDoc<ShoppingCartIndexDoc>(docId);
	if (!doc) {
		doc = Automerge.change(Automerge.init<ShoppingCartIndexDoc>(), 'init shopping cart index', (d) => {
			const init = shoppingCartIndexSchema.init();
			Object.assign(d, init);
			d.meta.spaceSlug = space;
		});
		_syncServer!.setDoc(docId, doc);
	}
	return doc;
}

/** Recompute index entry from a shopping cart doc. */
function reindexCart(space: string, cartId: string) {
	const cartDocId = shoppingCartDocId(space, cartId);
	const cartDoc = _syncServer!.getDoc<ShoppingCartDoc>(cartDocId);
	if (!cartDoc) return;

	const indexDocId = shoppingCartIndexDocId(space);
	ensureShoppingCartIndex(space);

	const items = cartDoc.items ? Object.values(cartDoc.items) : [];
	const totalAmount = items.reduce((sum, item) => sum + (item.price || 0) * item.quantity, 0);

	_syncServer!.changeDoc<ShoppingCartIndexDoc>(indexDocId, 'reindex cart', (d) => {
		d.carts[cartId] = {
			name: cartDoc.cart.name,
			status: cartDoc.cart.status as CartStatus,
			itemCount: items.length,
			totalAmount: Math.round(totalAmount * 100) / 100,
			fundedAmount: cartDoc.cart.fundedAmount,
			currency: cartDoc.cart.currency,
			createdAt: cartDoc.cart.createdAt,
			updatedAt: Date.now(),
		};
	});
}

// ── SHOPPING CART ROUTES ──

// POST /api/extract — Extract product from URL
routes.post("/api/extract", async (c) => {
	const { url } = await c.req.json();
	if (!url) return c.json({ error: "Required: url" }, 400);

	try {
		const product = await extractProductFromUrl(url);
		return c.json(product);
	} catch (err) {
		return c.json({
			error: "Failed to extract product",
			detail: err instanceof Error ? err.message : String(err),
		}, 502);
	}
});

// GET /api/shopping-carts — List carts from index
routes.get("/api/shopping-carts", async (c) => {
	const space = c.req.param("space") || "demo";
	const indexDoc = ensureShoppingCartIndex(space);
	const carts = Object.entries(indexDoc.carts || {}).map(([id, entry]) => ({
		id,
		...entry,
		createdAt: new Date(entry.createdAt).toISOString(),
		updatedAt: new Date(entry.updatedAt).toISOString(),
	}));
	carts.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
	return c.json({ carts });
});

// POST /api/shopping-carts — Create cart
routes.post("/api/shopping-carts", async (c) => {
	const space = c.req.param("space") || "demo";
	const { name, description = "", targetAmount = 0, currency = "USD", recipientAddress = null } = await c.req.json();
	if (!name) return c.json({ error: "Required: name" }, 400);

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

	const docId = shoppingCartDocId(space, cartId);
	const cartDoc = Automerge.change(Automerge.init<ShoppingCartDoc>(), 'create shopping cart', (d) => {
		const init = shoppingCartSchema.init();
		Object.assign(d, init);
		d.meta.spaceSlug = space;
		d.cart.id = cartId;
		d.cart.name = name;
		d.cart.description = description;
		d.cart.status = 'OPEN';
		d.cart.recipientAddress = recipientAddress || null;
		d.cart.targetAmount = targetAmount;
		d.cart.fundedAmount = 0;
		d.cart.currency = currency;
		d.cart.createdAt = now;
		d.cart.updatedAt = now;
	});
	_syncServer!.setDoc(docId, cartDoc);

	reindexCart(space, cartId);

	return c.json({ id: cartId, name, status: 'OPEN', created_at: new Date(now).toISOString() }, 201);
});

// GET /api/shopping-carts/:cartId — Full cart with items + contributions
routes.get("/api/shopping-carts/:cartId", async (c) => {
	const space = c.req.param("space") || "demo";
	const cartId = c.req.param("cartId");
	const docId = shoppingCartDocId(space, cartId);
	const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
	if (!doc) return c.json({ error: "Cart not found" }, 404);

	const items = Object.entries(doc.items || {}).map(([id, item]) => ({
		id, ...item, addedAt: new Date(item.addedAt).toISOString(),
	}));
	const contributions = Object.entries(doc.contributions || {}).map(([id, contrib]) => ({
		id, ...contrib,
		createdAt: new Date(contrib.createdAt).toISOString(),
		updatedAt: new Date(contrib.updatedAt).toISOString(),
	}));

	return c.json({
		id: doc.cart.id,
		name: doc.cart.name,
		description: doc.cart.description,
		status: doc.cart.status,
		recipientAddress: doc.cart.recipientAddress || null,
		targetAmount: doc.cart.targetAmount,
		fundedAmount: doc.cart.fundedAmount,
		currency: doc.cart.currency,
		createdAt: new Date(doc.cart.createdAt).toISOString(),
		updatedAt: new Date(doc.cart.updatedAt).toISOString(),
		items,
		contributions,
		events: doc.events || [],
	});
});

// PUT /api/shopping-carts/:cartId — Update cart
routes.put("/api/shopping-carts/:cartId", async (c) => {
	const space = c.req.param("space") || "demo";
	const cartId = c.req.param("cartId");
	const docId = shoppingCartDocId(space, cartId);
	const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
	if (!doc) return c.json({ error: "Cart not found" }, 404);

	const body = await c.req.json();
	const { name, description, status } = body;

	const validStatuses: CartStatus[] = ['OPEN', 'FUNDING', 'FUNDED', 'CHECKING_OUT', 'ORDERED', 'CLOSED'];
	if (status && !validStatuses.includes(status)) {
		return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400);
	}

	_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'update cart', (d) => {
		if (name !== undefined) d.cart.name = name;
		if (description !== undefined) d.cart.description = description;
		if (status) d.cart.status = status;
		d.cart.updatedAt = Date.now();
	});

	reindexCart(space, cartId);
	return c.json({ id: cartId, status: status || doc.cart.status });
});

// DELETE /api/shopping-carts/:cartId — Delete cart + remove from index
routes.delete("/api/shopping-carts/:cartId", async (c) => {
	const space = c.req.param("space") || "demo";
	const cartId = c.req.param("cartId");
	const docId = shoppingCartDocId(space, cartId);
	const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
	if (!doc) return c.json({ error: "Cart not found" }, 404);

	// Remove from index
	const indexDocId = shoppingCartIndexDocId(space);
	ensureShoppingCartIndex(space);
	_syncServer!.changeDoc<ShoppingCartIndexDoc>(indexDocId, 'remove cart from index', (d) => {
		delete d.carts[cartId];
	});

	// Note: Automerge docs aren't truly deleted, but removing from index effectively hides it
	_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'close cart', (d) => {
		d.cart.status = 'CLOSED';
		d.cart.updatedAt = Date.now();
	});

	return c.json({ deleted: true, id: cartId });
});

// POST /api/shopping-carts/:cartId/items — Add item
routes.post("/api/shopping-carts/:cartId/items", async (c) => {
	const space = c.req.param("space") || "demo";
	const cartId = c.req.param("cartId");
	const docId = shoppingCartDocId(space, cartId);
	const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
	if (!doc) return c.json({ error: "Cart not found" }, 404);

	const body = await c.req.json();
	const { url, product: preExtracted } = body;

	let productData: any;
	if (preExtracted) {
		// Extension sent pre-extracted product data
		productData = preExtracted;
		productData.sourceUrl = url || preExtracted.sourceUrl;
	} else if (url) {
		// Extract from URL server-side
		try {
			productData = await extractProductFromUrl(url);
		} catch (err) {
			return c.json({ error: "Failed to extract product", detail: err instanceof Error ? err.message : String(err) }, 502);
		}
	} else {
		return c.json({ error: "Required: url or product" }, 400);
	}

	const itemId = crypto.randomUUID();
	const now = Date.now();
	const domain = productData.vendor?.domain || productData.sourceUrl ? (() => {
		try { return new URL(productData.sourceUrl).hostname.replace(/^www\./, ''); } catch { return 'unknown'; }
	})() : 'unknown';

	_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'add item to cart', (d) => {
		d.items[itemId] = {
			name: productData.name || 'Unknown Product',
			price: productData.price ?? null,
			currency: productData.currency || 'USD',
			quantity: body.quantity || 1,
			sourceUrl: productData.sourceUrl || url || '',
			imageUrl: productData.imageUrl || null,
			description: productData.description || null,
			vendor: {
				name: productData.vendor?.name || domain,
				domain: productData.vendor?.domain || domain,
				platform: productData.vendor?.platform || null,
			},
			addedBy: null,
			addedAt: now,
			sku: productData.sku || null,
		};
		d.cart.updatedAt = now;
		d.events.push({
			type: 'item_added',
			actor: 'anonymous',
			detail: `Added ${productData.name || 'item'}`,
			timestamp: now,
		});
		if (d.events.length > 200) d.events.splice(0, d.events.length - 200);
	});

	reindexCart(space, cartId);

	return c.json({
		id: itemId,
		name: productData.name,
		price: productData.price,
		sourceUrl: productData.sourceUrl || url,
		imageUrl: productData.imageUrl,
	}, 201);
});

// PUT /api/shopping-carts/:cartId/items/:itemId — Update item quantity
routes.put("/api/shopping-carts/:cartId/items/:itemId", async (c) => {
	const space = c.req.param("space") || "demo";
	const cartId = c.req.param("cartId");
	const itemId = c.req.param("itemId");
	const docId = shoppingCartDocId(space, cartId);
	const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
	if (!doc) return c.json({ error: "Cart not found" }, 404);
	if (!doc.items?.[itemId]) return c.json({ error: "Item not found" }, 404);

	const { quantity } = await c.req.json();
	if (typeof quantity !== 'number' || quantity < 1) return c.json({ error: "quantity must be >= 1" }, 400);

	_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'update item quantity', (d) => {
		d.items[itemId].quantity = quantity;
		d.cart.updatedAt = Date.now();
	});

	reindexCart(space, cartId);
	return c.json({ id: itemId, quantity });
});

// DELETE /api/shopping-carts/:cartId/items/:itemId — Remove item
routes.delete("/api/shopping-carts/:cartId/items/:itemId", async (c) => {
	const space = c.req.param("space") || "demo";
	const cartId = c.req.param("cartId");
	const itemId = c.req.param("itemId");
	const docId = shoppingCartDocId(space, cartId);
	const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
	if (!doc) return c.json({ error: "Cart not found" }, 404);
	if (!doc.items?.[itemId]) return c.json({ error: "Item not found" }, 404);

	const itemName = doc.items[itemId].name;

	_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'remove item from cart', (d) => {
		delete d.items[itemId];
		d.cart.updatedAt = Date.now();
		d.events.push({
			type: 'item_removed',
			actor: 'anonymous',
			detail: `Removed ${itemName}`,
			timestamp: Date.now(),
		});
		if (d.events.length > 200) d.events.splice(0, d.events.length - 200);
	});

	reindexCart(space, cartId);
	return c.json({ deleted: true, id: itemId });
});

// POST /api/shopping-carts/:cartId/contribute — Add contribution
routes.post("/api/shopping-carts/:cartId/contribute", async (c) => {
	const space = c.req.param("space") || "demo";
	const cartId = c.req.param("cartId");
	const docId = shoppingCartDocId(space, cartId);
	const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
	if (!doc) return c.json({ error: "Cart not found" }, 404);

	const { amount, username = "Anonymous", paymentMethod = "MANUAL" } = await c.req.json();
	if (typeof amount !== 'number' || amount <= 0) return c.json({ error: "amount must be > 0" }, 400);

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

	_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'add contribution', (d) => {
		d.contributions[contribId] = {
			userId: null,
			username,
			amount,
			currency: d.cart.currency,
			paymentMethod,
			status: 'confirmed',
			txHash: null,
			createdAt: now,
			updatedAt: now,
		};
		d.cart.fundedAmount = Math.round((d.cart.fundedAmount + amount) * 100) / 100;
		d.cart.updatedAt = now;
		d.events.push({
			type: 'contribution',
			actor: username,
			detail: `Contributed $${amount.toFixed(2)}`,
			timestamp: now,
		});
		if (d.events.length > 200) d.events.splice(0, d.events.length - 200);
	});

	reindexCart(space, cartId);
	return c.json({ id: contribId, amount, fundedAmount: doc.cart.fundedAmount + amount }, 201);
});

// POST /api/shopping-carts/:cartId/contribute-pay — Create payment request for cart contribution
routes.post("/api/shopping-carts/:cartId/contribute-pay", async (c) => {
	const space = c.req.param("space") || "demo";
	const cartId = c.req.param("cartId");
	const docId = shoppingCartDocId(space, cartId);
	const doc = _syncServer!.getDoc<ShoppingCartDoc>(docId);
	if (!doc) return c.json({ error: "Cart not found" }, 404);

	if (!doc.cart.recipientAddress) {
		return c.json({ error: "Cart has no recipient wallet address" }, 400);
	}

	const { amount, username = "Anonymous", chainId = 84532, token: payToken = "USDC" } = await c.req.json();
	if (typeof amount !== 'number' || amount <= 0) return c.json({ error: "amount must be > 0" }, 400);

	const paymentId = crypto.randomUUID();
	const now = Date.now();
	const payDocId = paymentRequestDocId(space, paymentId);

	const payDoc = Automerge.change(Automerge.init<PaymentRequestDoc>(), 'create cart contribution payment', (d) => {
		const init = paymentRequestSchema.init();
		Object.assign(d, init);
		d.meta.spaceSlug = space;
		d.payment.id = paymentId;
		d.payment.description = `Contribution to "${doc.cart.name}"`;
		d.payment.amount = String(amount);
		d.payment.amountEditable = false;
		d.payment.token = payToken;
		d.payment.chainId = chainId;
		d.payment.recipientAddress = doc.cart.recipientAddress!;
		d.payment.fiatAmount = String(amount);
		d.payment.fiatCurrency = doc.cart.currency || 'USD';
		d.payment.creatorDid = '';
		d.payment.creatorUsername = username;
		d.payment.status = 'pending';
		d.payment.paymentType = 'single';
		d.payment.maxPayments = 0;
		d.payment.paymentCount = 0;
		d.payment.enabledMethods = { card: true, wallet: true, encryptid: true };
		d.payment.linkedCartId = cartId;
		d.payment.createdAt = now;
		d.payment.updatedAt = now;
		d.payment.expiresAt = 0;
	});
	_syncServer!.setDoc(payDocId, payDoc);

	const payUrl = `/rcart/pay/${paymentId}`;
	const fullPayUrl = buildSpaceUrl(space, `/rcart/pay/${paymentId}`);

	return c.json({ paymentId, payUrl, fullPayUrl }, 201);
});

// ── Extension shortcut routes ──

// POST /api/cart/quick-add — Simplified endpoint for extension
routes.post("/api/cart/quick-add", async (c) => {
	const space = c.req.param("space") || "demo";
	const { url, product, space: targetSpace } = await c.req.json();
	if (!url) return c.json({ error: "Required: url" }, 400);

	const effectiveSpace = targetSpace || space;

	// Find or create a default OPEN cart
	const indexDoc = ensureShoppingCartIndex(effectiveSpace);
	let activeCartId: string | null = null;

	for (const [id, entry] of Object.entries(indexDoc.carts || {})) {
		if (entry.status === 'OPEN') {
			activeCartId = id;
			break;
		}
	}

	if (!activeCartId) {
		// Create a default cart
		activeCartId = crypto.randomUUID();
		const now = Date.now();
		const docId = shoppingCartDocId(effectiveSpace, activeCartId);
		const cartDoc = Automerge.change(Automerge.init<ShoppingCartDoc>(), 'create default cart', (d) => {
			const init = shoppingCartSchema.init();
			Object.assign(d, init);
			d.meta.spaceSlug = effectiveSpace;
			d.cart.id = activeCartId!;
			d.cart.name = 'My Cart';
			d.cart.description = 'Default shopping cart';
			d.cart.status = 'OPEN';
			d.cart.createdAt = now;
			d.cart.updatedAt = now;
		});
		_syncServer!.setDoc(docId, cartDoc);
		reindexCart(effectiveSpace, activeCartId);
	}

	// Extract product data
	let productData: any;
	if (product) {
		productData = product;
		productData.sourceUrl = url;
	} else {
		try {
			productData = await extractProductFromUrl(url);
		} catch {
			productData = { name: url, sourceUrl: url };
		}
	}

	const itemId = crypto.randomUUID();
	const now = Date.now();
	const docId = shoppingCartDocId(effectiveSpace, activeCartId);
	const domain = (() => { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return 'unknown'; } })();

	_syncServer!.changeDoc<ShoppingCartDoc>(docId, 'quick-add item', (d) => {
		d.items[itemId] = {
			name: productData.name || 'Unknown Product',
			price: productData.price ?? null,
			currency: productData.currency || 'USD',
			quantity: 1,
			sourceUrl: url,
			imageUrl: productData.imageUrl || null,
			description: productData.description || null,
			vendor: {
				name: productData.vendor?.name || domain,
				domain: productData.vendor?.domain || domain,
				platform: productData.vendor?.platform || null,
			},
			addedBy: null,
			addedAt: now,
			sku: productData.sku || null,
		};
		d.cart.updatedAt = now;
	});

	reindexCart(effectiveSpace, activeCartId);

	return c.json({
		success: true,
		data: {
			name: productData.name || url,
			cartId: activeCartId,
			itemId,
		},
	}, 201);
});

// GET /api/cart/summary — Badge count for extension popup
routes.get("/api/cart/summary", async (c) => {
	const space = c.req.param("space") || "demo";
	const indexDoc = ensureShoppingCartIndex(space);

	let totalItems = 0;
	let totalAmount = 0;
	const vendorGroups: Array<{ vendor: { name: string; domain: string }; items: Array<{ name: string; price: number; quantity: number }>; subtotal: number }> = [];

	for (const [cartId, entry] of Object.entries(indexDoc.carts || {})) {
		if (entry.status === 'OPEN' || entry.status === 'FUNDING') {
			totalItems += entry.itemCount;
			totalAmount += entry.totalAmount;

			// Get full cart doc for vendor grouping
			const cartDocId = shoppingCartDocId(space, cartId);
			const cartDoc = _syncServer!.getDoc<ShoppingCartDoc>(cartDocId);
			if (cartDoc) {
				const byVendor: Record<string, typeof vendorGroups[0]> = {};
				for (const item of Object.values(cartDoc.items || {})) {
					const key = item.vendor.domain;
					if (!byVendor[key]) {
						byVendor[key] = { vendor: { name: item.vendor.name, domain: item.vendor.domain }, items: [], subtotal: 0 };
					}
					byVendor[key].items.push({ name: item.name, price: item.price || 0, quantity: item.quantity });
					byVendor[key].subtotal += (item.price || 0) * item.quantity;
				}
				vendorGroups.push(...Object.values(byVendor));
			}
		}
	}

	return c.json({
		success: true,
		data: {
			totalItems,
			totalAmount: Math.round(totalAmount * 100) / 100,
			currency: 'USD',
			vendorGroups,
		},
	});
});

// ── PAYMENT REQUEST helpers ──

/** Get all payment request docs for a space. */
function getSpacePaymentDocs(space: string): Array<{ docId: string; doc: Automerge.Doc<PaymentRequestDoc> }> {
	const prefix = `${space}:cart:payments:`;
	const results: Array<{ docId: string; doc: Automerge.Doc<PaymentRequestDoc> }> = [];
	for (const id of _syncServer!.listDocs()) {
		if (id.startsWith(prefix)) {
			const doc = _syncServer!.getDoc<PaymentRequestDoc>(id);
			if (doc) results.push({ docId: id, doc });
		}
	}
	return results;
}

// USDC contract addresses
const USDC_ADDRESSES: Record<number, string> = {
	8453: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',   // Base
	84532: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',   // Base Sepolia
	1: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',      // Ethereum
};

// ── PAYMENT REQUEST ROUTES ──

// POST /api/payments — Create payment request (auth required)
routes.post("/api/payments", async (c) => {
	const space = c.req.param("space") || "demo";
	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 {
		description, amount, amountEditable = false,
		token: payToken = 'USDC',
		chainId = 8453, recipientAddress,
		fiatAmount = null, fiatCurrency = 'USD',
		expiresIn = 0, // seconds, 0 = no expiry
		paymentType = 'single',
		maxPayments = 0,
		enabledMethods = { card: true, wallet: true, encryptid: true },
		interval = null,
	} = body;

	if (!description || !recipientAddress) {
		return c.json({ error: "Required: description, recipientAddress" }, 400);
	}
	if (!amountEditable && !amount) {
		return c.json({ error: "Required: amount (or set amountEditable: true)" }, 400);
	}

	const paymentId = crypto.randomUUID();
	const now = Date.now();
	const expiresAt = expiresIn > 0 ? now + expiresIn * 1000 : 0;

	const docId = paymentRequestDocId(space, paymentId);
	const payDoc = Automerge.change(Automerge.init<PaymentRequestDoc>(), 'create payment request', (d) => {
		const init = paymentRequestSchema.init();
		Object.assign(d, init);
		d.meta.spaceSlug = space;
		d.payment.id = paymentId;
		d.payment.description = description;
		d.payment.amount = amount ? String(amount) : '0';
		d.payment.amountEditable = !!amountEditable;
		d.payment.token = payToken;
		d.payment.chainId = chainId;
		d.payment.recipientAddress = recipientAddress;
		d.payment.fiatAmount = fiatAmount ? String(fiatAmount) : null;
		d.payment.fiatCurrency = fiatCurrency;
		d.payment.creatorDid = claims.sub;
		d.payment.creatorUsername = claims.username || '';
		d.payment.status = 'pending';
		d.payment.paymentType = (['single', 'subscription', 'payer_choice'].includes(paymentType)) ? paymentType : 'single';
		d.payment.maxPayments = Math.max(0, parseInt(maxPayments) || 0);
		d.payment.paymentCount = 0;
		d.payment.enabledMethods = {
			card: enabledMethods.card !== false,
			wallet: enabledMethods.wallet !== false,
			encryptid: enabledMethods.encryptid !== false,
		};
		// Subscription interval
		const validIntervals = ['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly'];
		if (interval && validIntervals.includes(interval)) {
			d.payment.interval = interval;
		}
		d.payment.createdAt = now;
		d.payment.updatedAt = now;
		d.payment.expiresAt = expiresAt;
	});
	_syncServer!.setDoc(docId, payDoc);

	const host = c.req.header("host") || "rspace.online";
	const payUrl = `${buildSpaceUrl(space, "/rcart")}/pay/${paymentId}`;

	return c.json({
		id: paymentId,
		description,
		amount: String(amount),
		token: payToken,
		chainId,
		recipientAddress,
		status: 'pending',
		payUrl,
		qrUrl: `${buildSpaceUrl(space, "/rcart")}/api/payments/${paymentId}/qr`,
		created_at: new Date(now).toISOString(),
	}, 201);
});

// GET /api/payments — List my payment requests (auth required)
routes.get("/api/payments", async (c) => {
	const space = c.req.param("space") || "demo";
	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 paymentDocs = getSpacePaymentDocs(space);
	const payments = paymentDocs
		.map(({ doc }) => doc.payment)
		.filter((p) => p.creatorDid === claims.sub)
		.sort((a, b) => b.createdAt - a.createdAt)
		.map((p) => ({
			id: p.id,
			description: p.description,
			amount: p.amount,
			token: p.token,
			chainId: p.chainId,
			recipientAddress: p.recipientAddress,
			fiatAmount: p.fiatAmount,
			fiatCurrency: p.fiatCurrency,
			status: p.status,
			paymentMethod: p.paymentMethod,
			txHash: p.txHash,
			created_at: new Date(p.createdAt).toISOString(),
			paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
		}));

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

// GET /api/payments/:id — Get payment details (public)
routes.get("/api/payments/:id", async (c) => {
	const space = c.req.param("space") || "demo";
	const paymentId = c.req.param("id");
	const docId = paymentRequestDocId(space, paymentId);
	const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
	if (!doc) return c.json({ error: "Payment request not found" }, 404);

	const p = doc.payment;

	// Check expiry
	if (p.expiresAt > 0 && Date.now() > p.expiresAt && p.status === 'pending') {
		_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'expire payment', (d) => {
			d.payment.status = 'expired';
			d.payment.updatedAt = Date.now();
		});
		return c.json({
			...paymentToResponse(p),
			status: 'expired',
			usdcAddress: USDC_ADDRESSES[p.chainId] || null,
		});
	}

	// Check inventory fill
	if (p.maxPayments > 0 && p.paymentCount >= p.maxPayments && p.status === 'pending') {
		_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'fill payment', (d) => {
			d.payment.status = 'filled';
			d.payment.updatedAt = Date.now();
		});
		return c.json({
			...paymentToResponse(p),
			status: 'filled',
			usdcAddress: USDC_ADDRESSES[p.chainId] || null,
		});
	}

	return c.json({
		...paymentToResponse(p),
		usdcAddress: USDC_ADDRESSES[p.chainId] || null,
	});
});

// PATCH /api/payments/:id/status — Update payment status
routes.patch("/api/payments/:id/status", async (c) => {
	const space = c.req.param("space") || "demo";
	const paymentId = c.req.param("id");
	const docId = paymentRequestDocId(space, paymentId);
	const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
	if (!doc) return c.json({ error: "Payment request not found" }, 404);

	const body = await c.req.json();
	const { status, txHash, paymentMethod, payerIdentity, transakOrderId, amount, chosenPaymentType, payerEmail } = body;
	const validStatuses = ['pending', 'paid', 'confirmed', 'expired', 'cancelled', 'filled'];
	if (status && !validStatuses.includes(status)) {
		return c.json({ error: `status must be one of: ${validStatuses.join(", ")}` }, 400);
	}

	// Check if inventory is full before allowing payment
	const p = doc.payment;
	if (status === 'paid' && p.maxPayments > 0 && p.paymentCount >= p.maxPayments) {
		return c.json({ error: "This payment request has reached its limit" }, 400);
	}

	const now = Date.now();
	_syncServer!.changeDoc<PaymentRequestDoc>(docId, `payment status → ${status || 'update'}`, (d) => {
		if (status) d.payment.status = status;
		if (txHash) d.payment.txHash = txHash;
		if (paymentMethod) d.payment.paymentMethod = paymentMethod;
		if (payerIdentity) d.payment.payerIdentity = payerIdentity;
		if (transakOrderId) d.payment.transakOrderId = transakOrderId;
		// Allow amount update only if payment is editable and still pending
		if (amount && d.payment.amountEditable && d.payment.status === 'pending') {
			d.payment.amount = String(amount);
		}
		// Store subscriber email for reminder notifications
		if (payerEmail && !d.payment.subscriberEmail) {
			d.payment.subscriberEmail = payerEmail;
		}
		d.payment.updatedAt = now;
		if (status === 'paid') {
			d.payment.paidAt = now;
			d.payment.paymentCount = (d.payment.paymentCount || 0) + 1;

			// Record payment in history
			if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any;
			(d.payment.paymentHistory as any).push({
				txHash: txHash || null,
				transakOrderId: transakOrderId || null,
				paymentMethod: paymentMethod || null,
				payerIdentity: payerIdentity || null,
				payerEmail: payerEmail || null,
				amount: d.payment.amount,
				paidAt: now,
			});

			// Determine if this payment should stay open for more payments
			const effectiveType = d.payment.paymentType === 'payer_choice'
				? (chosenPaymentType === 'subscription' ? 'subscription' : 'single')
				: d.payment.paymentType;
			const isRecurring = effectiveType === 'subscription' || d.payment.maxPayments > 1;
			if (isRecurring) {
				if (d.payment.maxPayments > 0 && d.payment.paymentCount >= d.payment.maxPayments) {
					d.payment.status = 'filled';
				} else {
					// Keep accepting payments — reset status after recording
					d.payment.status = 'pending';

					// Compute next due date based on interval
					if (d.payment.interval) {
						d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval);
					}
				}
			}
		}
	});

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

	// Fire-and-forget payment emails to both payer and recipient
	if (status === 'paid') {
		const host = c.req.header("host") || "rspace.online";
		const isValidEmail = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e);

		// Resolve payer email: from request body, or look up via EncryptID
		(async () => {
			let resolvedPayerEmail = payerEmail;
			if (!resolvedPayerEmail && payerIdentity) {
				const lookup = await lookupEncryptIDEmail(payerIdentity);
				if (lookup.email) resolvedPayerEmail = lookup.email;
			}
			// Send "Payment Sent" email to payer
			if (resolvedPayerEmail && isValidEmail(resolvedPayerEmail)) {
				sendPaymentSuccessEmail(resolvedPayerEmail, updated!.payment, host, space)
					.catch((err) => console.error('[rcart] payer email failed:', err));
			}
			// Send "Payment Received" email to recipient (creator)
			const creatorDid = updated!.payment.creatorDid;
			if (creatorDid) {
				const creatorLookup = await lookupEncryptIDEmail(creatorDid);
				if (creatorLookup.email && isValidEmail(creatorLookup.email)) {
					sendPaymentReceivedEmail(creatorLookup.email, updated!.payment, host, space, resolvedPayerEmail)
						.catch((err) => console.error('[rcart] recipient email failed:', err));
				}
			}
		})().catch((err) => console.error('[rcart] payment email resolution failed:', err));
	}

	// Auto-record contribution on linked shopping cart
	if (status === 'paid' && updated!.payment.linkedCartId) {
		const linkedCartId = updated!.payment.linkedCartId;
		const cartDocId = shoppingCartDocId(space, linkedCartId);
		const cartDoc = _syncServer!.getDoc<ShoppingCartDoc>(cartDocId);
		if (cartDoc) {
			const contribAmount = parseFloat(updated!.payment.amount) || 0;
			if (contribAmount > 0) {
				const contribId = crypto.randomUUID();
				const contribNow = Date.now();
				_syncServer!.changeDoc<ShoppingCartDoc>(cartDocId, 'auto-record payment contribution', (d) => {
					d.contributions[contribId] = {
						userId: null,
						username: updated!.payment.creatorUsername || 'Anonymous',
						amount: contribAmount,
						currency: d.cart.currency,
						paymentMethod: updated!.payment.paymentMethod || 'wallet',
						status: 'confirmed',
						txHash: updated!.payment.txHash || null,
						createdAt: contribNow,
						updatedAt: contribNow,
					};
					d.cart.fundedAmount = Math.round((d.cart.fundedAmount + contribAmount) * 100) / 100;
					d.cart.updatedAt = contribNow;
					d.events.push({
						type: 'contribution',
						actor: updated!.payment.creatorUsername || 'Anonymous',
						detail: `Paid $${contribAmount.toFixed(2)} via ${updated!.payment.paymentMethod || 'wallet'}`,
						timestamp: contribNow,
					});
					if (d.events.length > 200) d.events.splice(0, d.events.length - 200);
				});
				reindexCart(space, linkedCartId);
			}
		}
	}

	return c.json(paymentToResponse(updated!.payment));
});

// GET /api/payments/:id/qr — QR code SVG
routes.get("/api/payments/:id/qr", async (c) => {
	const space = c.req.param("space") || "demo";
	const paymentId = c.req.param("id");
	const docId = paymentRequestDocId(space, paymentId);
	const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
	if (!doc) return c.json({ error: "Payment request not found" }, 404);

	const host = c.req.header("host") || "rspace.online";
	const payUrl = `${buildSpaceUrl(space, "/rcart")}/pay/${paymentId}`;

	const svg = await QRCode.toString(payUrl, { type: 'svg', margin: 2 });
	return c.body(svg, 200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' });
});

// POST /api/payments/:id/transak-session — Get Transak widget URL (public)
routes.post("/api/payments/:id/transak-session", async (c) => {
	const space = c.req.param("space") || "demo";
	const paymentId = c.req.param("id");
	const docId = paymentRequestDocId(space, paymentId);
	const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
	if (!doc) return c.json({ error: "Payment request not found" }, 404);

	const p = doc.payment;
	if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
	if (p.enabledMethods && !p.enabledMethods.card) return c.json({ error: "Card payments are not enabled for this request" }, 400);

	const { email, amount: overrideAmount } = await c.req.json();
	if (!email) return c.json({ error: "Required: email" }, 400);

	const transakApiKey = getTransakApiKey();
	if (!transakApiKey) return c.json({ error: "Transak not configured" }, 503);

	const networkMap: Record<number, string> = { 8453: 'base', 84532: 'base', 1: 'ethereum' };

	// Use forwarded host from reverse proxy, fall back to request URL
	const host = c.req.header('x-forwarded-host') || c.req.header('host') || new URL(c.req.url).hostname;

	// Use override amount for editable-amount payments, otherwise use preset amount
	const effectiveAmount = (p.amountEditable && overrideAmount) ? String(overrideAmount) : p.amount;

	const widgetParams: Record<string, string> = {
		apiKey: transakApiKey,
		referrerDomain: extractRootDomain(host),
		cryptoCurrencyCode: p.token,
		network: networkMap[p.chainId] || 'base',
		defaultCryptoCurrency: p.token,
		walletAddress: p.recipientAddress,
		disableWalletAddressForm: 'true',
		defaultCryptoAmount: effectiveAmount,
		partnerOrderId: `pay-${paymentId}`,
		email,
		isAutoFillUserData: 'true',
		hideExchangeScreen: 'true',
		paymentMethod: 'credit_debit_card',
		themeColor: '6366f1',
		colorMode: 'DARK',
		hideMenu: 'true',
	};

	// Derive fiat amount: use explicit fiatAmount, or infer from crypto amount for stablecoins
	const inferredFiat = p.fiatAmount || (USD_STABLECOINS.includes(p.token) ? effectiveAmount : null);
	if (inferredFiat) {
		widgetParams.fiatAmount = inferredFiat;
		widgetParams.defaultFiatAmount = inferredFiat;
	}
	const fiatCcy = p.fiatCurrency || (USD_STABLECOINS.includes(p.token) ? 'USD' : null);
	if (fiatCcy) {
		widgetParams.fiatCurrency = fiatCcy;
		widgetParams.defaultFiatCurrency = fiatCcy;
	}

	const widgetUrl = await createSecureWidgetUrl(widgetParams);
	const transakEnv = getTransakEnv();

	return c.json({ widgetUrl, env: transakEnv });
});

// POST /api/payments/:id/card-session — Get on-ramp widget URL (MoonPay preferred, Transak fallback)
routes.post("/api/payments/:id/card-session", async (c) => {
	const space = c.req.param("space") || "demo";
	const paymentId = c.req.param("id");
	const docId = paymentRequestDocId(space, paymentId);
	const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
	if (!doc) return c.json({ error: "Payment request not found" }, 404);

	const p = doc.payment;
	if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);
	if (p.enabledMethods && !p.enabledMethods.card) return c.json({ error: "Card payments are not enabled for this request" }, 400);

	const { email, amount: overrideAmount } = await c.req.json();
	if (!email) return c.json({ error: "Required: email" }, 400);

	const effectiveAmount = (p.amountEditable && overrideAmount) ? String(overrideAmount) : p.amount;

	// Try MoonPay first (simpler, no session API needed)
	const moonPayKey = getMoonPayApiKey();
	if (moonPayKey) {
		try {
			const widgetUrl = createMoonPayPaymentUrl({
				walletAddress: p.recipientAddress,
				token: p.token,
				chainId: p.chainId,
				amount: effectiveAmount,
				fiatAmount: p.fiatAmount || undefined,
				fiatCurrency: p.fiatCurrency || 'USD',
				email,
				paymentId,
			});
			return c.json({ widgetUrl, provider: 'moonpay', env: getMoonPayEnv() });
		} catch (err) {
			console.error('[rcart] MoonPay URL generation failed:', err);
		}
	}

	// Fall back to Transak
	const transakApiKey = getTransakApiKey();
	if (!transakApiKey) return c.json({ error: "No payment provider configured" }, 503);

	const networkMap: Record<number, string> = { 8453: 'base', 84532: 'base', 1: 'ethereum' };
	const host = c.req.header('x-forwarded-host') || c.req.header('host') || new URL(c.req.url).hostname;

	const widgetParams: Record<string, string> = {
		apiKey: transakApiKey,
		referrerDomain: extractRootDomain(host),
		cryptoCurrencyCode: p.token,
		network: networkMap[p.chainId] || 'base',
		defaultCryptoCurrency: p.token,
		walletAddress: p.recipientAddress,
		disableWalletAddressForm: 'true',
		defaultCryptoAmount: effectiveAmount,
		partnerOrderId: `pay-${paymentId}`,
		email,
		isAutoFillUserData: 'true',
		hideExchangeScreen: 'true',
		paymentMethod: 'credit_debit_card',
		themeColor: '6366f1',
		colorMode: 'DARK',
		hideMenu: 'true',
	};
	// Derive fiat amount: use explicit fiatAmount, or infer from crypto amount for stablecoins
	{
		const inferredFiat = p.fiatAmount || (USD_STABLECOINS.includes(p.token) ? effectiveAmount : null);
		if (inferredFiat) {
			widgetParams.fiatAmount = inferredFiat;
			widgetParams.defaultFiatAmount = inferredFiat;
		}
		const fiatCcy = p.fiatCurrency || (USD_STABLECOINS.includes(p.token) ? 'USD' : null);
		if (fiatCcy) {
			widgetParams.fiatCurrency = fiatCcy;
			widgetParams.defaultFiatCurrency = fiatCcy;
		}
	}

	const widgetUrl = await createSecureWidgetUrl(widgetParams);
	return c.json({ widgetUrl, provider: 'transak', env: getTransakEnv() });
});

// POST /api/payments/:id/share-email — Email payment link to recipients
routes.post("/api/payments/:id/share-email", async (c) => {
	const space = c.req.param("space") || "demo";
	const paymentId = c.req.param("id");
	const docId = paymentRequestDocId(space, paymentId);
	const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
	if (!doc) return c.json({ error: "Payment request not found" }, 404);

	const p = doc.payment;
	if (p.status !== 'pending') return c.json({ error: "Payment is no longer pending" }, 400);

	const { emails } = await c.req.json();
	if (!Array.isArray(emails) || emails.length === 0) return c.json({ error: "Required: emails array" }, 400);
	if (emails.length > 50) return c.json({ error: "Maximum 50 recipients per request" }, 400);

	const transport = getSmtpTransport();
	if (!transport) return c.json({ error: "Email not configured" }, 503);

	const host = c.req.header("host") || "rspace.online";
	const payUrl = `${buildSpaceUrl(space, "/rcart")}/pay/${paymentId}`;
	const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
	const chainName = chainNames[p.chainId] || `Chain ${p.chainId}`;
	const displayAmount = (!p.amount || p.amount === '0') && p.amountEditable ? 'any amount' : `${p.amount} ${p.token}`;
	const senderName = p.creatorUsername || 'Someone';
	const fromAddr = process.env.SMTP_FROM || process.env.SMTP_USER || "noreply@rmail.online";

	const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<tr><td style="background:linear-gradient(135deg,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
  <h1 style="color:#fff;font-size:24px;margin:0">Payment Request</h1>
  <p style="color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:14px">from ${senderName}</p>
</td></tr>
<tr><td style="background:#1a1a24;padding:32px 24px">
  <p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0 0 16px">
    <strong>${senderName}</strong> has sent you a payment request${p.description ? ` for <strong>${p.description}</strong>` : ''}.
  </p>
  <table width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px">
    <tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Amount</td>
        <td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right;font-weight:600">${displayAmount}</td></tr>
    <tr><td style="color:#94a3b8;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d">Network</td>
        <td style="color:#fff;font-size:13px;padding:8px 0;border-bottom:1px solid #2d2d3d;text-align:right">${chainName}</td></tr>
    <tr><td style="color:#94a3b8;font-size:13px;padding:8px 0">Wallet</td>
        <td style="color:#fff;font-size:13px;padding:8px 0;text-align:right;font-family:monospace">${p.recipientAddress.slice(0, 8)}...${p.recipientAddress.slice(-6)}</td></tr>
  </table>
  <table width="100%" cellpadding="0" cellspacing="0">
    <tr><td align="center">
      <a href="${payUrl}" style="display:inline-block;background:linear-gradient(135deg,#06b6d4,#8b5cf6);color:#fff;padding:14px 32px;border-radius:8px;text-decoration:none;font-weight:600;font-size:16px">
        Pay Now
      </a>
    </td></tr>
  </table>
</td></tr>
<tr><td style="background:#13131a;border-radius:0 0 12px 12px;padding:16px 24px;text-align:center">
  <p style="color:#64748b;font-size:12px;margin:0">Powered by rSpace &middot; <a href="${payUrl}" style="color:#67e8f9;text-decoration:none">View payment page</a></p>
</td></tr>
</table>
</td></tr>
</table>
</body></html>`;

	let sent = 0;
	const validEmails = emails.filter((e: string) => typeof e === 'string' && e.includes('@'));
	for (const email of validEmails) {
		try {
			await transport.sendMail({
				from: `"${senderName} via rSpace" <${fromAddr}>`,
				to: email.trim(),
				subject: `Payment request from ${senderName}${p.description ? `: ${p.description}` : ''}`,
				html,
			});
			sent++;
		} catch (err) {
			console.warn(`[rcart] Failed to send payment email to ${email}:`, err);
		}
	}

	return c.json({ sent, total: validEmails.length });
});

// ── Recurring payment endpoints ──

// GET /api/payments/:id/subscription-info — Get subscription approval details
routes.get("/api/payments/:id/subscription-info", async (c) => {
	const space = c.req.param("space") || "demo";
	const paymentId = c.req.param("id");
	const docId = paymentRequestDocId(space, paymentId);
	const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
	if (!doc) return c.json({ error: "Payment request not found" }, 404);

	const p = doc.payment;
	if (p.paymentType !== 'subscription' && p.paymentType !== 'payer_choice') {
		return c.json({ error: "Not a subscription payment" }, 400);
	}

	const usdcAddress = USDC_ADDRESSES[p.chainId];
	if (!usdcAddress) return c.json({ error: "Token not supported on this chain" }, 400);

	try {
		const relayerAddress = await getRelayerAddress();
		const decimals = p.token === 'USDC' ? 6 : 18;

		// Build the approve calldata for the client
		const approval = await buildApprovalCalldata(
			p.amount || '0', decimals, p.maxPayments
		);

		return c.json({
			relayerAddress,
			tokenAddress: usdcAddress,
			chainId: p.chainId,
			interval: p.interval,
			amountPerPayment: p.amount,
			token: p.token,
			approveCalldata: approval.calldata,
			totalAllowance: approval.totalAllowance,
		});
	} catch (e) {
		return c.json({ error: "Recurring payments not configured on this server" }, 503);
	}
});

// POST /api/payments/:id/subscribe — Register a subscription after payer approves allowance
routes.post("/api/payments/:id/subscribe", async (c) => {
	const space = c.req.param("space") || "demo";
	const paymentId = c.req.param("id");
	const docId = paymentRequestDocId(space, paymentId);
	const doc = _syncServer!.getDoc<PaymentRequestDoc>(docId);
	if (!doc) return c.json({ error: "Payment request not found" }, 404);

	const p = doc.payment;
	const body = await c.req.json();
	const { payerAddress, email, txHash } = body;

	if (!payerAddress) return c.json({ error: "Required: payerAddress" }, 400);

	const usdcAddress = USDC_ADDRESSES[p.chainId];
	if (!usdcAddress) return c.json({ error: "Token not supported on this chain" }, 400);

	// Verify the allowance exists on-chain
	try {
		const allowance = await checkAllowance(usdcAddress, payerAddress, p.chainId);
		const decimals = p.token === 'USDC' ? 6 : 18;
		const perPayment = parseTokenAmountServer(p.amount || '0', decimals);

		if (allowance < perPayment) {
			return c.json({
				error: "Insufficient allowance. Please approve the relayer to spend your tokens first.",
				allowance: allowance.toString(),
				required: perPayment.toString(),
			}, 400);
		}
	} catch (e) {
		return c.json({ error: "Failed to verify allowance on-chain" }, 500);
	}

	// Store subscription info
	const now = Date.now();
	_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'register subscription', (d) => {
		d.payment.subscriberEmail = email || null;
		// Set payer address for automated pulls
		d.payment.payerIdentity = payerAddress;
		// First due date is one interval from now
		if (d.payment.interval) {
			d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval);
		}
		d.payment.updatedAt = now;
	});

	// If this was the first payment (approval tx included a transfer), record it
	if (txHash) {
		_syncServer!.changeDoc<PaymentRequestDoc>(docId, 'record initial payment', (d) => {
			d.payment.status = 'pending'; // Keep accepting
			d.payment.paidAt = now;
			d.payment.paymentCount = (d.payment.paymentCount || 0) + 1;
			d.payment.txHash = txHash;
			if (!d.payment.paymentHistory) d.payment.paymentHistory = [] as any;
			(d.payment.paymentHistory as any).push({
				txHash,
				transakOrderId: null,
				paymentMethod: 'wallet',
				payerIdentity: payerAddress,
				payerEmail: email || null,
				amount: d.payment.amount,
				paidAt: now,
			});
			if (d.payment.interval) {
				d.payment.nextDueAt = computeNextDueDate(now, d.payment.interval);
			}
		});
	}

	return c.json({
		subscribed: true,
		nextDueAt: doc.payment.nextDueAt ? new Date(doc.payment.nextDueAt).toISOString() : null,
		interval: doc.payment.interval,
	});
});

// Server-side token amount parser
function parseTokenAmountServer(amount: string, decimals: number): bigint {
	const parts = amount.split('.');
	const whole = parts[0] || '0';
	const frac = (parts[1] || '').slice(0, decimals).padEnd(decimals, '0');
	return BigInt(whole) * BigInt(10 ** decimals) + BigInt(frac);
}

// ── Payment success email ──

const CHAIN_NAMES: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
const CHAIN_EXPLORERS: Record<number, string> = {
	8453: 'https://basescan.org/tx/',
	84532: 'https://sepolia.basescan.org/tx/',
	1: 'https://etherscan.io/tx/',
};

async function sendPaymentSuccessEmail(
	email: string,
	p: PaymentRequestMeta,
	host: string,
	space: string,
) {
	const transport = getSmtpTransport();
	if (!transport) {
		console.warn('[rcart] SMTP not configured — skipping payment email');
		return;
	}

	const chainName = CHAIN_NAMES[p.chainId] || `Chain ${p.chainId}`;
	const explorer = CHAIN_EXPLORERS[p.chainId];
	const txLink = explorer && p.txHash
		? `<a href="${explorer}${p.txHash}" style="color:#67e8f9;text-decoration:none">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
		: (p.txHash || 'N/A');
	const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString();
	const rflowsUrl = `${buildSpaceUrl(space, "/rflows")}`;
	const dashboardUrl = `${buildSpaceUrl(space, "/rcart")}`;

	const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">

<!-- Header -->
<tr><td style="background:linear-gradient(135deg,#06b6d4,#8b5cf6);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
  <div style="font-size:48px;margin-bottom:8px">&#10003;</div>
  <h1 style="margin:0;color:#fff;font-size:24px;font-weight:700">Payment Received</h1>
</td></tr>

<!-- Body -->
<tr><td style="background:#1a1a24;padding:28px 24px">

  <!-- Details table -->
  <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px">
    <tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px;width:120px">Amount</td>
        <td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px;font-weight:600">${p.amount} ${p.token}${p.fiatAmount ? ` (\u2248 $${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}</td></tr>
    <tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Network</td>
        <td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${chainName}</td></tr>
    <tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Method</td>
        <td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${p.paymentMethod || 'N/A'}</td></tr>
    <tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Transaction</td>
        <td style="padding:10px 0;border-bottom:1px solid #2a2a3a;font-size:14px">${txLink}</td></tr>
    <tr><td style="padding:10px 0;color:#9ca3af;font-size:14px">Date</td>
        <td style="padding:10px 0;color:#f0f0f5;font-size:14px">${paidDate}</td></tr>
  </table>

  <!-- What happens next -->
  <div style="background:#12121a;border:1px solid #2a2a3a;border-radius:8px;padding:20px;margin-bottom:24px">
    <h2 style="margin:0 0 8px;color:#f0f0f5;font-size:16px;font-weight:600">What happens next</h2>
    <p style="margin:0 0 16px;color:#9ca3af;font-size:14px;line-height:1.6">
      Your contribution flows into a funding flow that distributes resources across the project.
      Track how funds are allocated in real time.
    </p>
    <a href="${rflowsUrl}" style="display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#06b6d4,#8b5cf6);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:8px">View rFlows</a>
  </div>

  <!-- Resources -->
  <div style="margin-bottom:8px">
    <h2 style="margin:0 0 10px;color:#f0f0f5;font-size:16px;font-weight:600">Resources</h2>
    <p style="margin:0 0 6px;font-size:14px;line-height:1.6">
      <a href="https://psilo-cyber.net/ics" style="color:#67e8f9;text-decoration:none">Interplanetary Coordination System</a>
    </p>
    <p style="margin:0;color:#6b7280;font-size:13px;line-height:1.6">
      Endosymbiotic Finance (coming soon)
    </p>
  </div>

</td></tr>

<!-- Footer -->
<tr><td style="background:#12121a;border-radius:0 0 12px 12px;padding:20px 24px;text-align:center;border-top:1px solid #2a2a3a">
  <p style="margin:0;color:#6b7280;font-size:12px">
    Sent by <a href="${dashboardUrl}" style="color:#67e8f9;text-decoration:none">rSpace</a>
  </p>
</td></tr>

</table>
</td></tr>
</table>
</body></html>`;

	const text = [
		`Payment Received`,
		``,
		`Amount: ${p.amount} ${p.token}${p.fiatAmount ? ` (~$${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}`,
		`Network: ${chainName}`,
		`Method: ${p.paymentMethod || 'N/A'}`,
		`Transaction: ${p.txHash || 'N/A'}`,
		`Date: ${paidDate}`,
		``,
		`What happens next:`,
		`Your contribution flows into a funding flow that distributes resources across the project.`,
		`View rFlows: ${rflowsUrl}`,
		``,
		`Resources:`,
		`Interplanetary Coordination System: https://psilo-cyber.net/ics`,
		`Endosymbiotic Finance (coming soon)`,
		``,
		`Sent by rSpace — ${dashboardUrl}`,
	].join('\n');

	await transport.sendMail({
		from: process.env.SMTP_FROM || 'rSpace <noreply@rmail.online>',
		to: email,
		subject: `Payment confirmed \u2014 ${p.amount} ${p.token}`,
		html,
		text,
	});
}

async function sendPaymentReceivedEmail(
	email: string,
	p: PaymentRequestMeta,
	host: string,
	space: string,
	payerEmail?: string,
) {
	const transport = getSmtpTransport();
	if (!transport) return;

	const chainName = CHAIN_NAMES[p.chainId] || `Chain ${p.chainId}`;
	const explorer = CHAIN_EXPLORERS[p.chainId];
	const txLink = explorer && p.txHash
		? `<a href="${explorer}${p.txHash}" style="color:#67e8f9;text-decoration:none">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
		: (p.txHash || 'N/A');
	const paidDate = p.paidAt ? new Date(p.paidAt).toUTCString() : new Date().toUTCString();
	const dashboardUrl = `${buildSpaceUrl(space, "/rcart")}`;
	const payerLabel = payerEmail || p.payerIdentity?.slice(0, 10) + '...' || 'Anonymous';

	const html = `<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#0f0f14;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#0f0f14;padding:32px 16px">
<tr><td align="center">
<table width="560" cellpadding="0" cellspacing="0" style="max-width:560px;width:100%">
<tr><td style="background:linear-gradient(135deg,#10b981,#06b6d4);border-radius:12px 12px 0 0;padding:32px 24px;text-align:center">
  <div style="font-size:48px;margin-bottom:8px">&#128176;</div>
  <h1 style="margin:0;color:#fff;font-size:24px;font-weight:700">You Received a Payment</h1>
</td></tr>
<tr><td style="background:#1a1a24;padding:28px 24px">
  <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px">
    <tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px;width:120px">Amount</td>
        <td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px;font-weight:600">${p.amount} ${p.token}${p.fiatAmount ? ` (\u2248 $${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}</td></tr>
    <tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">From</td>
        <td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${payerLabel}</td></tr>
    <tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Network</td>
        <td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#f0f0f5;font-size:14px">${chainName}</td></tr>
    <tr><td style="padding:10px 0;border-bottom:1px solid #2a2a3a;color:#9ca3af;font-size:14px">Transaction</td>
        <td style="padding:10px 0;border-bottom:1px solid #2a2a3a;font-size:14px">${txLink}</td></tr>
    <tr><td style="padding:10px 0;color:#9ca3af;font-size:14px">Date</td>
        <td style="padding:10px 0;color:#f0f0f5;font-size:14px">${paidDate}</td></tr>
  </table>
  <div style="text-align:center;margin-bottom:8px">
    <a href="${dashboardUrl}" style="display:inline-block;padding:10px 24px;background:linear-gradient(135deg,#10b981,#06b6d4);color:#fff;font-size:14px;font-weight:600;text-decoration:none;border-radius:8px">View Dashboard</a>
  </div>
</td></tr>
<tr><td style="background:#12121a;border-radius:0 0 12px 12px;padding:20px 24px;text-align:center;border-top:1px solid #2a2a3a">
  <p style="margin:0;color:#6b7280;font-size:12px">
    Sent by <a href="${dashboardUrl}" style="color:#67e8f9;text-decoration:none">rSpace</a>
  </p>
</td></tr>
</table></td></tr></table></body></html>`;

	const text = [
		`You Received a Payment`,
		``,
		`Amount: ${p.amount} ${p.token}${p.fiatAmount ? ` (~$${p.fiatAmount} ${p.fiatCurrency || 'USD'})` : ''}`,
		`From: ${payerLabel}`,
		`Network: ${chainName}`,
		`Transaction: ${p.txHash || 'N/A'}`,
		`Date: ${paidDate}`,
		``,
		`View Dashboard: ${dashboardUrl}`,
		``,
		`Sent by rSpace — ${dashboardUrl}`,
	].join('\n');

	await transport.sendMail({
		from: process.env.SMTP_FROM || 'rSpace <noreply@rmail.online>',
		to: email,
		subject: `Payment received \u2014 ${p.amount} ${p.token}`,
		html,
		text,
	});
	console.log(`[rcart] Payment received email sent to ${email}`);
}

function paymentToResponse(p: PaymentRequestMeta) {
	return {
		id: p.id,
		description: p.description,
		amount: p.amount,
		amountEditable: p.amountEditable,
		token: p.token,
		chainId: p.chainId,
		recipientAddress: p.recipientAddress,
		fiatAmount: p.fiatAmount,
		fiatCurrency: p.fiatCurrency,
		status: p.status,
		paymentMethod: p.paymentMethod,
		txHash: p.txHash,
		payerIdentity: p.payerIdentity,
		transakOrderId: p.transakOrderId,
		paymentType: p.paymentType || 'single',
		maxPayments: p.maxPayments || 0,
		paymentCount: p.paymentCount || 0,
		enabledMethods: p.enabledMethods || { card: true, wallet: true, encryptid: true },
		creatorUsername: p.creatorUsername || '',
		linkedCartId: p.linkedCartId || null,
		interval: p.interval || null,
		nextDueAt: p.nextDueAt ? new Date(p.nextDueAt).toISOString() : null,
		paymentHistory: (p.paymentHistory || []).map(h => ({
			txHash: h.txHash,
			paymentMethod: h.paymentMethod,
			amount: h.amount,
			paidAt: new Date(h.paidAt).toISOString(),
		})),
		created_at: new Date(p.createdAt).toISOString(),
		updated_at: new Date(p.updatedAt).toISOString(),
		paid_at: p.paidAt ? new Date(p.paidAt).toISOString() : null,
		expires_at: p.expiresAt ? new Date(p.expiresAt).toISOString() : null,
	};
}

// ── GROUP BUY ROUTES ──

// POST /api/group-buys — Create a group buy from a catalog entry
routes.post("/api/group-buys", async (c) => {
	const space = c.req.param("space") || "demo";
	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 { catalogEntryId, tiers, description, closesInDays } = body;
	if (!catalogEntryId || !tiers?.length) return c.json({ error: "catalogEntryId and tiers[] required" }, 400);

	const catDoc = _syncServer!.getDoc<CatalogDoc>(catalogDocId(space));
	const entry = catDoc?.items?.[catalogEntryId];
	if (!entry) return c.json({ error: "Catalog entry not found" }, 404);

	const buyId = crypto.randomUUID();
	const now = Date.now();
	const docId = groupBuyDocId(space, buyId);

	const doc = Automerge.change(Automerge.init<GroupBuyDoc>(), 'create group buy', (d) => {
		const init = groupBuySchema.init();
		Object.assign(d, init);
		d.meta.spaceSlug = space;
		d.meta.createdAt = now;
		d.buy.id = buyId;
		d.buy.catalogEntryId = catalogEntryId;
		d.buy.artifactId = entry.artifactId;
		d.buy.title = entry.title;
		d.buy.productType = entry.productType;
		d.buy.imageUrl = (entry as any).imageUrl || null;
		d.buy.description = description || '';
		d.buy.status = 'OPEN';
		d.buy.totalPledged = 0;
		d.buy.createdBy = claims.sub || null;
		d.buy.createdAt = now;
		d.buy.updatedAt = now;
		d.buy.closesAt = closesInDays ? now + closesInDays * 86400000 : now + 30 * 86400000;
		for (const tier of tiers) {
			d.buy.tiers.push({ min_qty: tier.min_qty, per_unit: tier.per_unit, currency: tier.currency || 'USD' });
		}
	});
	_syncServer!.setDoc(docId, doc);

	const host = c.req.header('host') || 'rspace.online';
	const shareUrl = `${buildSpaceUrl(space, "/rcart")}/group-buy/${buyId}`;
	return c.json({ id: buyId, shareUrl }, 201);
});

// GET /api/group-buys — List open group buys for a space
routes.get("/api/group-buys", (c) => {
	const space = c.req.param("space") || "demo";
	const prefix = `${space}:cart:group-buys:`;
	const buys: any[] = [];
	for (const id of _syncServer!.listDocs()) {
		if (id.startsWith(prefix)) {
			const doc = _syncServer!.getDoc<GroupBuyDoc>(id);
			if (doc?.buy) {
				buys.push({
					id: doc.buy.id,
					title: doc.buy.title,
					productType: doc.buy.productType,
					imageUrl: doc.buy.imageUrl,
					status: doc.buy.status,
					totalPledged: doc.buy.totalPledged,
					tiers: doc.buy.tiers,
					closesAt: new Date(doc.buy.closesAt).toISOString(),
					createdAt: new Date(doc.buy.createdAt).toISOString(),
				});
			}
		}
	}
	return c.json({ groupBuys: buys });
});

// GET /api/group-buys/:id — Get group buy details
routes.get("/api/group-buys/:id", (c) => {
	const space = c.req.param("space") || "demo";
	const buyId = c.req.param("id");
	const docId = groupBuyDocId(space, buyId);
	const doc = _syncServer!.getDoc<GroupBuyDoc>(docId);
	if (!doc) return c.json({ error: "Group buy not found" }, 404);

	const pledges = Object.values(doc.pledges || {}).map(p => ({
		id: p.id,
		displayName: p.displayName,
		quantity: p.quantity,
		pledgedAt: new Date(p.pledgedAt).toISOString(),
	}));

	const currentTier = [...doc.buy.tiers].reverse().find(t => doc.buy.totalPledged >= t.min_qty) || doc.buy.tiers[0] || null;

	return c.json({
		id: doc.buy.id,
		catalogEntryId: doc.buy.catalogEntryId,
		title: doc.buy.title,
		productType: doc.buy.productType,
		imageUrl: doc.buy.imageUrl,
		description: doc.buy.description,
		tiers: doc.buy.tiers,
		status: doc.buy.status,
		totalPledged: doc.buy.totalPledged,
		currentTier,
		pledges,
		closesAt: new Date(doc.buy.closesAt).toISOString(),
		createdAt: new Date(doc.buy.createdAt).toISOString(),
		updatedAt: new Date(doc.buy.updatedAt).toISOString(),
	});
});

// POST /api/group-buys/:id/pledge — Add a pledge
routes.post("/api/group-buys/:id/pledge", async (c) => {
	const space = c.req.param("space") || "demo";
	const buyId = c.req.param("id");
	const docId = groupBuyDocId(space, buyId);
	const doc = _syncServer!.getDoc<GroupBuyDoc>(docId);
	if (!doc) return c.json({ error: "Group buy not found" }, 404);
	if (doc.buy.status !== 'OPEN') return c.json({ error: "Group buy is no longer open" }, 400);

	const body = await c.req.json();
	const { quantity, displayName } = body;
	if (!quantity || quantity < 1) return c.json({ error: "quantity must be >= 1" }, 400);

	let buyerId: string | null = null;
	const token = extractToken(c.req.raw.headers);
	if (token) {
		try { const claims = await verifyToken(token); buyerId = claims.sub || null; } catch { /* public pledge */ }
	}

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

	_syncServer!.changeDoc<GroupBuyDoc>(docId, 'add pledge', (d) => {
		d.pledges[pledgeId] = {
			id: pledgeId,
			buyerId,
			displayName: displayName || 'Anonymous',
			quantity,
			pledgedAt: now,
		};
		d.buy.totalPledged += quantity;
		d.buy.updatedAt = now;
	});

	const updated = _syncServer!.getDoc<GroupBuyDoc>(docId)!;
	const currentTier = [...updated.buy.tiers].reverse().find(t => updated.buy.totalPledged >= t.min_qty) || updated.buy.tiers[0] || null;

	return c.json({
		pledgeId,
		totalPledged: updated.buy.totalPledged,
		currentTier,
	}, 201);
});

// ── Page route: group buy page ──
routes.get("/group-buy/:id", (c) => {
	const space = c.req.param("space") || "demo";
	const buyId = c.req.param("id");
	return c.html(renderShell({
		title: `Group Buy | rCart`,
		moduleId: "rcart",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-group-buy-page space="${space}" buy-id="${buyId}"></folk-group-buy-page>`,
		scripts: `<script type="module" src="/modules/rcart/folk-group-buy-page.js"></script>`,
		styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
	}));
});

// ── Page route: payments dashboard ──
routes.get("/payments", (c) => {
	const space = c.req.param("space") || "demo";
	return c.html(renderShell({
		title: `Payments | rCart`,
		moduleId: "rcart",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-payments-dashboard space="${space}"></folk-payments-dashboard>`,
		scripts: `<script type="module" src="/modules/rcart/folk-payments-dashboard.js"></script>`,
		styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
	}));
});

// ── Page route: request payment (self-service QR generator) ──
routes.get("/request", (c) => {
	const space = c.req.param("space") || "demo";
	return c.html(renderShell({
		title: `Request Payment | rCart`,
		moduleId: "rcart",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-payment-request space="${space}"></folk-payment-request>`,
		scripts: `<script type="module" src="/modules/rcart/folk-payment-request.js"></script>`,
		styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
	}));
});

// ── Page route: payment page ──
routes.get("/pay/:id", (c) => {
	const space = c.req.param("space") || "demo";
	const paymentId = c.req.param("id");

	// Check payment status server-side for graceful terminal-state messages
	const docId = paymentRequestDocId(space, paymentId);
	const doc = _syncServer?.getDoc<PaymentRequestDoc>(docId);
	if (doc) {
		const p = doc.payment;
		const terminalStates: Record<string, { title: string; msg: string; icon: string }> = {
			paid:      { title: 'Payment Complete', msg: 'This payment request has already been paid.', icon: '&#10003;' },
			confirmed: { title: 'Payment Confirmed', msg: 'This payment has been confirmed on-chain.', icon: '&#10003;' },
			expired:   { title: 'Payment Expired', msg: 'This payment request has expired and is no longer accepting payments.', icon: '&#9202;' },
			cancelled: { title: 'Payment Cancelled', msg: 'This payment request has been cancelled by the creator.', icon: '&#10007;' },
			filled:    { title: 'Payment Limit Reached', msg: 'This payment request has reached its maximum number of payments.', icon: '&#10003;' },
		};
		const info = terminalStates[p.status];
		if (info) {
			const chainNames: Record<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
			const explorerBase: Record<number, string> = { 8453: 'https://basescan.org/tx/', 84532: 'https://sepolia.basescan.org/tx/', 1: 'https://etherscan.io/tx/' };
			const txLink = p.txHash && explorerBase[p.chainId]
				? `<a href="${explorerBase[p.chainId]}${p.txHash}" target="_blank" rel="noopener" style="color:#60a5fa;text-decoration:underline">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
				: '';
			return c.html(renderShell({
				title: `${info.title} | rCart`,
				moduleId: "rcart",
				spaceSlug: space,
				spaceVisibility: "public",
				modules: getModuleInfoList(),
				theme: "dark",
				body: `
					<div style="max-width:480px;margin:60px auto;padding:32px;text-align:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#e2e8f0">
						<div style="font-size:48px;margin-bottom:16px;color:${p.status === 'paid' || p.status === 'confirmed' || p.status === 'filled' ? '#4ade80' : p.status === 'expired' ? '#fbbf24' : '#f87171'}">${info.icon}</div>
						<h1 style="font-size:24px;font-weight:600;margin:0 0 12px">${info.title}</h1>
						<p style="color:#94a3b8;font-size:15px;line-height:1.6;margin:0 0 24px">${info.msg}</p>
						${p.amount && p.amount !== '0' ? `<div style="font-size:20px;font-weight:600;margin-bottom:8px">${p.amount} ${p.token}</div>` : ''}
						${p.fiatAmount ? `<div style="color:#94a3b8;font-size:14px;margin-bottom:16px">&asymp; $${p.fiatAmount} ${p.fiatCurrency || 'USD'}</div>` : ''}
						${chainNames[p.chainId] ? `<div style="color:#64748b;font-size:13px;margin-bottom:8px">Network: ${chainNames[p.chainId]}</div>` : ''}
						${txLink ? `<div style="font-size:13px;margin-bottom:8px">Tx: ${txLink}</div>` : ''}
						${p.paidAt ? `<div style="color:#64748b;font-size:13px">Paid: ${new Date(p.paidAt).toLocaleString()}</div>` : ''}
					</div>`,
				styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
			}));
		}
	}

	return c.html(renderShell({
		title: `Payment | rCart`,
		moduleId: "rcart",
		spaceSlug: space,
		spaceVisibility: "public", // Payment pages are always public
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-payment-page space="${space}" payment-id="${paymentId}"></folk-payment-page>`,
		scripts: `<script type="module" src="/modules/rcart/folk-payment-page.js"></script>`,
		styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
	}));
});

// ── Page routes: shop views (subnav tab links) ──

function renderShop(space: string, view?: string) {
	const viewAttr = view ? ` initial-view="${view}"` : "";
	return renderShell({
		title: `${space} — Shop | rSpace`,
		moduleId: "rcart",
		spaceSlug: space,
		modules: getModuleInfoList(),
		theme: "dark",
		body: `<folk-cart-shop space="${space}"${viewAttr}></folk-cart-shop>`,
		scripts: `<script type="module" src="/modules/rcart/folk-cart-shop.js?v=2"></script>`,
		styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
	});
}

routes.get("/carts", (c) => c.html(renderShop(c.req.param("space") || "demo", "carts")));
routes.get("/catalog", (c) => c.html(renderShop(c.req.param("space") || "demo", "catalog")));
routes.get("/orders", (c) => c.html(renderShop(c.req.param("space") || "demo", "orders")));
routes.get("/group-buys", (c) => c.html(renderShop(c.req.param("space") || "demo", "group-buys")));
routes.get("/subscriptions", (c) => c.html(renderShop(c.req.param("space") || "demo", "subscriptions")));

routes.get("/", (c) => c.html(renderShop(c.req.param("space") || "demo")));

// ── Seed template data ──

function seedTemplateCart(space: string) {
	if (!_syncServer) return;
	const doc = ensureCatalogDoc(space);
	if (Object.keys(doc.items).length > 0) return;

	const docId = catalogDocId(space);
	const now = Date.now();

	const items: Array<{ title: string; type: string; caps: string[]; subs: string[]; tags: string[] }> = [
		{
			title: 'Commons Community Sticker Pack', type: 'sticker',
			caps: ['laser-print'], subs: ['vinyl-matte'],
			tags: ['merch', 'stickers'],
		},
		{
			title: 'Cosmolocal Network Poster (A2)', type: 'poster',
			caps: ['risograph'], subs: ['paper-heavyweight'],
			tags: ['merch', 'poster', 'cosmolocal'],
		},
		{
			title: 'rSpace Contributor Tee', type: 'apparel',
			caps: ['screen-print'], subs: ['cotton-organic'],
			tags: ['merch', 'apparel'],
		},
	];

	_syncServer.changeDoc<CatalogDoc>(docId, 'seed template catalog', (d) => {
		for (const item of items) {
			const id = crypto.randomUUID();
			d.items[id] = {
				id, artifactId: crypto.randomUUID(), artifact: null,
				title: item.title, productType: item.type,
				requiredCapabilities: item.caps, substrates: item.subs,
				creatorId: 'did:demo:seed', sourceSpace: space,
				tags: item.tags, status: 'active',
				createdAt: now, updatedAt: now,
			};
		}
	});

	console.log(`[Cart] Template seeded for "${space}": 3 catalog entries`);
}

export function getRecentOrdersForMI(space: string, limit = 5): { id: string; title: string; status: string; totalPrice: number; createdAt: number }[] {
	if (!_syncServer) return [];
	const items: { id: string; title: string; status: string; totalPrice: number; createdAt: number }[] = [];
	for (const docId of _syncServer.listDocs()) {
		if (!docId.startsWith(`${space}:cart:orders:`)) continue;
		const doc = _syncServer.getDoc<OrderDoc>(docId);
		if (!doc?.order) continue;
		const o = doc.order;
		items.push({ id: o.id, title: o.catalogEntryId, status: o.status, totalPrice: o.totalPrice || 0, createdAt: o.createdAt });
	}
	return items.sort((a, b) => b.createdAt - a.createdAt).slice(0, limit);
}

export const cartModule: RSpaceModule = {
	id: "rcart",
	name: "rCart",
	icon: "🛒",
	description: "Group shopping & cosmolocal print-on-demand shop",
	publicWrite: true,
	scoping: { defaultScope: 'space', userConfigurable: false },
	docSchemas: [
		{ pattern: '{space}:cart:catalog', description: 'Product catalog', init: catalogSchema.init },
		{ pattern: '{space}:cart:orders:{orderId}', description: 'Order document', init: orderSchema.init },
		{ pattern: '{space}:cart:shopping:{cartId}', description: 'Shopping cart', init: shoppingCartSchema.init },
		{ pattern: '{space}:cart:shopping-index', description: 'Shopping cart index', init: shoppingCartIndexSchema.init },
		{ pattern: '{space}:cart:payments:{paymentId}', description: 'Payment request', init: paymentRequestSchema.init },
	],
	routes,
	standaloneDomain: "rcart.online",
	landingPage: renderLanding,
	seedTemplate: seedTemplateCart,
	async onInit(ctx) {
		_syncServer = ctx.syncServer;
		startSubscriptionScheduler();
	},
	feeds: [
		{
			id: "orders",
			name: "Orders",
			kind: "economic",
			description: "Order stream with pricing, fulfillment status, and revenue splits",
			filterable: true,
		},
		{
			id: "catalog",
			name: "Catalog",
			kind: "data",
			description: "Active catalog listings with product details and pricing",
			filterable: true,
		},
		{
			id: "shopping",
			name: "Shopping Carts",
			kind: "data",
			description: "Group shopping carts with pooled items and contributions",
			filterable: true,
		},
	],
	acceptsFeeds: ["economic", "data"],
	outputPaths: [
		{ path: "carts", name: "Carts", icon: "🛒", description: "Group shopping carts" },
		{ path: "catalog", name: "Catalog", icon: "🛍️", description: "Print-on-demand product catalog" },
		{ path: "orders", name: "Orders", icon: "📦", description: "Order history and fulfillment tracking" },
		{ path: "payments", name: "Payments", icon: "💳", description: "Payment requests and invoices" },
		{ path: "group-buys", name: "Group Buys", icon: "👥", description: "Volume discount group purchasing campaigns" },
		{ path: "subscriptions", name: "Subscriptions", icon: "🔄", description: "Recurring subscription orders" },
	],
	onboardingActions: [
		{ label: "Create a Store", icon: "🏪", description: "Set up a community storefront", type: 'create', href: '/{space}/rcart' },
	],
};
