import express from "express";
import path from "node:path";
import crypto from "node:crypto";
import bcrypt from "bcryptjs";
import { fetchPageText } from "./fetch-page.js";
import { runPrefill } from "./prefill.js";
import { buildUpdatedAlog, parseAlog } from "./alog.js";
import { listAlogLibrary, readAlogFromLibrary } from "./alog-library.js";
import { evaluateRoast as defaultEvaluateRoast } from "./evaluate-roast.js";
import { listAvailableModels as defaultListModels } from "./llm.js";
import { buildOpenApiSpec } from "./openapi.js";
import { computeRoasterProfile } from "./roaster-profile.js";
import { coerceChatMessages, runPlanChat as defaultRunPlanChat } from "./plan-chat.js";
import { BREW_CATEGORIES, BREW_METHODS } from "../shared/brew-data.js";
import { createRequire } from "node:module";
import { sendMail } from "./mailer.js";
import { coerceSession, computeTotalScore, blankSession } from "../shared/cupping.js";
import { computeMachineProfile } from "../shared/learn.js";

const hash = (value) => crypto.createHash("sha256").update(value).digest("hex");
const token = () => crypto.randomBytes(32).toString("base64url");
const ADMIN_EMAIL = "snowspeeder@gmail.com";
const UUID_RE =
	/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const emailOf = (value) =>
	String(value || "")
		.trim()
		.toLowerCase();
const PASSWORD_OK = (value) =>
	typeof value === "string" && value.length >= 12 && value.length <= 256;
// Compared against when no user row exists, so a non-existent email costs the same bcrypt work
// as a real one and login can't be used to enumerate accounts by response timing.
const DUMMY_PASSWORD_HASH = bcrypt.hashSync("no-such-account-password", 12);
// Pages the browser reaches only through the routes below — never served directly by name,
// so anonymous-vs-authenticated redirects and no-store headers can never be bypassed.
const PUBLIC_SHELL_FILES = new Set([
	"/index.html",
	"/admin.html",
	"/account.html",
	"/landing.html",
	"/login.html",
	"/signup.html",
	"/forgot.html",
	"/reset.html",
	"/setup.html",
	"/inventory.html",
	"/cupping.html",
	"/roasts.html",
	"/beans.html",
	"/brews.html",
	"/gear.html",
	"/roasters.html",
	"/academy.html",
	"/api-docs.html",
]);

/** Creates the HTTP app separately from listening, so tests can use an isolated database. */
export function createApp({
	db,
	root,
	env = process.env,
	// Injectable so tests can stub the LLM calls; production always uses the real ones.
	evaluateRoast = defaultEvaluateRoast,
	listModels = defaultListModels,
	runPlanChat = defaultRunPlanChat,
} = {}) {
	const app = express();
	const production = env.NODE_ENV === "production";
	const cookieSecure = env.COOKIE_SECURE
		? env.COOKIE_SECURE === "true"
		: production;
	const origin = env.APP_ORIGIN || (production ? "https://roast.srmr.xyz" : "");
	const mailFrom = env.SMTP_FROM || "Roast Planner <no-reply@roast.srmr.xyz>";
	// Password-reset links when no SMTP is configured: kept only in memory (never persisted),
	// so an admin can hand a user their link without the raw token ever touching the database.
	const pendingResets = new Map(); // token_hash -> { email, url, expiresAt }
	const buckets = new Map();
	const MAX_RATE_BUCKETS = 10_000;
	const rateLimit = (name, max, windowMs) => {
		if (
			!Number.isInteger(max) ||
			max < 1 ||
			max > 1_000 ||
			!Number.isInteger(windowMs) ||
			windowMs < 1_000 ||
			windowMs > 3_600_000
		)
			throw new Error("Invalid rate-limit configuration");
		return (req, res, next) => {
			const key = `${name}:${req.ip}`;
			const now = Date.now();
			for (const [bucketKey, bucket] of buckets) {
				if (bucket.reset <= now) buckets.delete(bucketKey);
			}
			if (buckets.size >= MAX_RATE_BUCKETS && !buckets.has(key))
				return res.status(429).json({ ok: false, code: "rate_limited" });
			const bucket = buckets.get(key) || { count: 0, reset: now + windowMs };
			bucket.count++;
			buckets.set(key, bucket);
			res.set("RateLimit-Limit", String(max));
			res.set("RateLimit-Reset", String(Math.ceil(bucket.reset / 1_000)));
			if (bucket.count > max)
				return res.status(429).json({ ok: false, code: "rate_limited" });
			next();
		};
	};
	// Per-email login lockout: independent of the IP-keyed rateLimit above, so many accounts
	// sharing one proxy IP cannot lock each other out, and one attacker cannot brute-force a
	// single account from many IPs either.
	const loginFailures = new Map();
	const LOGIN_MAX_FAILURES = 10;
	const LOGIN_LOCK_WINDOW = 15 * 60_000;
	const MAX_LOGIN_TRACKED = 10_000;
	function emailLocked(email) {
		const entry = loginFailures.get(email);
		return !!entry && entry.reset > Date.now() && entry.count >= LOGIN_MAX_FAILURES;
	}
	function recordLoginFailure(email) {
		const now = Date.now();
		for (const [key, entry] of loginFailures) {
			if (entry.reset <= now) loginFailures.delete(key);
		}
		if (loginFailures.size >= MAX_LOGIN_TRACKED && !loginFailures.has(email))
			return;
		let entry = loginFailures.get(email);
		if (!entry || entry.reset <= now)
			entry = { count: 0, reset: now + LOGIN_LOCK_WINDOW };
		entry.count++;
		loginFailures.set(email, entry);
	}
	const clearLoginFailures = (email) => loginFailures.delete(email);

	app.disable("x-powered-by");
	// Do not accept client-supplied forwarding headers unless the deployment explicitly
	// identifies its proxy. A numeric hop count is unsafe when the topology changes.
	app.set("trust proxy", env.TRUST_PROXY || false);
	app.use((req, res, next) => {
		if (
			req.path.startsWith("/api/") ||
			req.path === "/app" ||
			req.path === "/admin" ||
			req.path === "/account" ||
			req.path === "/inventory" ||
			req.path === "/cupping" ||
			req.path === "/roasts" ||
			req.path === "/beans" ||
			req.path === "/brews" ||
			req.path === "/gear" ||
			req.path === "/roasters" ||
			req.path === "/academy" ||
			req.path === "/api-docs"
		)
			res.set("Cache-Control", "no-store, private");
		res.set({
			"X-Content-Type-Options": "nosniff",
			"X-Frame-Options": "DENY",
			"Referrer-Policy": "strict-origin-when-cross-origin",
			"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
			"Cross-Origin-Opener-Policy": "same-origin",
			"Content-Security-Policy":
				"default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:",
		});
		next();
	});
	// Finished-roast uploads carry a whole Artisan .alog (full telemetry arrays) inside a JSON
	// string — those legitimately run to a few MB — and a full-database restore can be far
	// larger still. Those specific routes get bigger body caps without loosening the 1mb limit
	// everything else keeps.
	const jsonBody = express.json({ limit: "1mb" });
	const jsonBodyLarge = express.json({ limit: "8mb" });
	const jsonBodyBackup = express.json({ limit: "256mb" });
	app.use((req, res, next) => {
		const parser =
			req.method === "POST" && req.path === "/api/admin/backup/import"
				? jsonBodyBackup
				: req.method === "POST" && req.path === "/api/roasts"
					? jsonBodyLarge
					: jsonBody;
		return parser(req, res, next);
	});
	// express.json() leaves req.body undefined when the request has no body or a non-JSON
	// content-type (Express 5 no longer defaults it to {}), so every route below that reads
	// req.body.<field> would 500 instead of validating and returning 400.
	app.use((req, _res, next) => {
		req.body ??= {};
		next();
	});
	const cookie = (req, name) =>
		Object.fromEntries(
			(req.headers.cookie || "")
				.split(";")
				.map((x) => x.trim().split("="))
				.filter((x) => x[0]),
		)[name];
	const setSessionCookie = (res, value, maxAge, csrfToken = "") => {
		res.cookie("rp_session", value, {
			httpOnly: true,
			secure: cookieSecure,
			sameSite: "lax",
			path: "/",
			maxAge,
		});
		res.cookie("rp_csrf", csrfToken, {
			httpOnly: false,
			secure: cookieSecure,
			sameSite: "lax",
			path: "/",
			maxAge,
		});
	};
	async function session(req) {
		// API tokens: an Authorization: Bearer header authenticates exactly like a session for
		// the owning user. Header-borne credentials can't be sent by a cross-site form, so
		// token-authenticated requests are exempt from CSRF (see csrf() below).
		const authHeader = req.get("authorization") || "";
		if (authHeader.startsWith("Bearer ")) {
			const rawToken = authHeader.slice(7).trim();
			if (!rawToken) return null;
			const r = await db.query(
				"SELECT u.id,u.email,u.role,u.created_at FROM api_tokens t JOIN users u ON u.id=t.user_id WHERE t.token_hash=$1 AND u.disabled_at IS NULL",
				[hash(rawToken)],
			);
			const row = r.rows[0];
			if (!row) return null;
			db.query(
				"UPDATE api_tokens SET last_used_at=now() WHERE token_hash=$1",
				[hash(rawToken)],
			).catch(() => {});
			return { ...row, token_auth: true };
		}
		const raw = cookie(req, "rp_session");
		if (!raw) return null;
		const r = await db.query(
			"SELECT s.csrf_hash,u.id,u.email,u.role,u.created_at,(u.avatar IS NOT NULL) AS has_avatar FROM sessions s JOIN users u ON u.id=s.user_id WHERE s.token_hash=$1 AND s.expires_at>now() AND u.disabled_at IS NULL",
			[hash(raw)],
		);
		const row = r.rows[0];
		if (!row) return null;
		// Sliding expiry + last-seen, throttled to once/hour so an active user is never logged
		// out mid-session; best-effort and never blocks the request.
		db.query(
			"UPDATE sessions SET expires_at=now()+interval '14 days', last_seen_at=now() WHERE token_hash=$1 AND (last_seen_at IS NULL OR last_seen_at<now()-interval '1 hour')",
			[hash(raw)],
		).catch(() => {});
		return row;
	}
	async function requireAuth(req, res, next) {
		try {
			req.user = await session(req);
			if (!req.user)
				return res.status(401).json({ ok: false, code: "unauthorized" });
			next();
		} catch (e) {
			next(e);
		}
	}
	const csrf = (req, res, next) => {
		// Bearer-token requests carry no cookies for a cross-site attacker to ride on.
		if (req.user?.token_auth) return next();
		if (origin && req.get("origin") && req.get("origin") !== origin)
			return res.status(403).json({ ok: false, code: "bad_origin" });
		const value = req.get("x-csrf-token");
		if (
			!value ||
			value !== cookie(req, "rp_csrf") ||
			!req.user ||
			!crypto.timingSafeEqual(
				Buffer.from(hash(value)),
				Buffer.from(req.user.csrf_hash),
			)
		)
			return res.status(403).json({ ok: false, code: "csrf_failed" });
		next();
	};
	const admin = (req, res, next) =>
		req.user.role === "admin"
			? next()
			: res.status(403).json({ ok: false, code: "forbidden" });
	const requireUuidParam = (name) => (req, res, next) =>
		UUID_RE.test(req.params[name])
			? next()
			: res.status(404).json({ ok: false, code: "not_found" });
	const isSelf = (req) => req.params.id === req.user.id;
	const isBootstrapAdmin = async (id) => {
		const r = await db.query("SELECT 1 FROM users WHERE id=$1 AND email=$2", [
			id,
			ADMIN_EMAIL,
		]);
		return !!r.rowCount;
	};
	async function audit(actorUserId, action, target = null) {
		try {
			await db.query(
				"INSERT INTO audit_events(actor_user_id,action,target) VALUES($1,$2,$3)",
				[actorUserId, action, target],
			);
		} catch (e) {
			console.error("audit_log_failed", action, e);
		}
	}
	/** Runs `fn(client)` inside BEGIN/COMMIT on a single checked-out connection — required for
	 * genuine atomicity (pool.query() calls are not guaranteed to share a connection, so a bare
	 * sequence of db.query("BEGIN")/db.query(...)/db.query("COMMIT") does not actually protect
	 * against a crash or error between statements the way it looks like it does). */
	async function withTransaction(fn) {
		const client = await db.connect();
		try {
			await client.query("BEGIN");
			const result = await fn(client);
			await client.query("COMMIT");
			return result;
		} catch (e) {
			await client.query("ROLLBACK").catch(() => {});
			throw e;
		} finally {
			client.release();
		}
	}
	const createSession = async (user, req) => {
		const raw = token(),
			csrfToken = token();
		await db.query("DELETE FROM sessions WHERE expires_at < now()").catch(() => {});
		await db.query(
			"INSERT INTO sessions(token_hash,user_id,csrf_hash,expires_at,user_agent,ip,last_seen_at) VALUES($1,$2,$3,now()+interval '14 days',$4,$5,now())",
			[
				hash(raw),
				user.id,
				hash(csrfToken),
				req?.get("user-agent")?.slice(0, 256) || null,
				req?.ip || null,
			],
		);
		return { raw, csrfToken };
	};

	// ─── Public shell pages ────────────────────────────────────────────────
	app.get("/", async (req, res, next) => {
		try {
			// Returning users should not be left on the marketing page after signing in.
			if (await session(req)) return res.status(302).location("/app").end();
			res.set("Cache-Control", "no-store, private");
			res.sendFile(path.join(root, "public", "landing.html"));
		} catch (error) {
			next(error);
		}
	});
	for (const route of ["/login", "/signup", "/forgot"]) {
		app.get(route, async (req, res, next) => {
			try {
				if (await session(req)) return res.status(302).location("/app").end();
				res.set("Cache-Control", "no-store, private");
				res.sendFile(path.join(root, "public", `${route.slice(1)}.html`));
			} catch (error) {
				next(error);
			}
		});
	}
	app.get("/reset", async (req, res, next) => {
		try {
			if (await session(req)) return res.status(302).location("/app").end();
			res.set("Cache-Control", "no-store, private");
			res.sendFile(path.join(root, "public", "reset.html"));
		} catch (error) {
			next(error);
		}
	});
	app.get("/setup", async (req, res, next) => {
		try {
			if (await session(req)) return res.status(302).location("/app").end();
			const exists = await db.query("SELECT 1 FROM users WHERE email=$1", [
				ADMIN_EMAIL,
			]);
			if (exists.rowCount) return res.status(302).location("/login").end();
			res.set("Cache-Control", "no-store, private");
			res.sendFile(path.join(root, "public", "setup.html"));
		} catch (error) {
			next(error);
		}
	});
	// These three serve HTML to a browser navigation, so on failure they redirect to another
	// page rather than the requireAuth/admin API middleware's JSON 401/403 — a signed-out visit
	// or an expired session must land back on /login, not a bare JSON error page.
	app.get("/app", async (req, res, next) => {
		try {
			const user = await session(req);
			if (!user) return res.redirect("/login");
			res.sendFile(path.join(root, "public", "index.html"));
		} catch (error) {
			next(error);
		}
	});
	app.get("/account", async (req, res, next) => {
		try {
			const user = await session(req);
			if (!user) return res.redirect("/login");
			res.sendFile(path.join(root, "public", "account.html"));
		} catch (error) {
			next(error);
		}
	});
	app.get("/inventory", async (req, res, next) => {
		try {
			const user = await session(req);
			if (!user) return res.redirect("/login");
			res.sendFile(path.join(root, "public", "inventory.html"));
		} catch (error) {
			next(error);
		}
	});
	app.get("/cupping", async (req, res, next) => {
		try {
			const user = await session(req);
			if (!user) return res.redirect("/login");
			res.sendFile(path.join(root, "public", "cupping.html"));
		} catch (error) {
			next(error);
		}
	});
	app.get("/roasts", async (req, res, next) => {
		try {
			const user = await session(req);
			if (!user) return res.redirect("/login");
			res.sendFile(path.join(root, "public", "roasts.html"));
		} catch (error) {
			next(error);
		}
	});
	for (const [route, file] of [
		["/beans", "beans.html"],
		["/brews", "brews.html"],
		["/gear", "gear.html"],
		["/roasters", "roasters.html"],
		["/academy", "academy.html"],
		["/api-docs", "api-docs.html"],
	]) {
		app.get(route, async (req, res, next) => {
			try {
				const user = await session(req);
				if (!user) return res.redirect("/login");
				res.sendFile(path.join(root, "public", file));
			} catch (error) {
				next(error);
			}
		});
	}
	app.get("/admin", async (req, res, next) => {
		try {
			const user = await session(req);
			if (!user) return res.redirect("/login");
			if (user.role !== "admin") return res.redirect("/app");
			res.sendFile(path.join(root, "public", "admin.html"));
		} catch (error) {
			next(error);
		}
	});

	// ─── Auth ──────────────────────────────────────────────────────────────
	app.get("/api/auth/signup-enabled", async (_req, res, next) => {
		try {
			const setting = await db.query(
				"SELECT value FROM app_settings WHERE key='signup_enabled'",
			);
			res.json({ ok: true, enabled: setting.rows[0]?.value === "true" });
		} catch (e) {
			next(e);
		}
	});
	app.post(
		"/api/auth/signup",
		rateLimit("signup", 8, 60_000),
		async (req, res, next) => {
			try {
				const email = emailOf(req.body.email),
					password = req.body.password;
				if (!/^\S+@\S+\.\S+$/.test(email) || !PASSWORD_OK(password))
					return res.status(400).json({
						ok: false,
						code: "invalid_credentials",
						error:
							"Use a valid email and a password of at least 12 characters.",
					});
				// Reserved for the one-time /api/auth/bootstrap flow — never claimable by regular
				// signup, or a fresh deployment's first visitor could permanently lock out the
				// real administrator before they ever get to set it up.
				if (email === ADMIN_EMAIL)
					return res.status(409).json({ ok: false, code: "email_exists" });
				const setting = await db.query(
					"SELECT value FROM app_settings WHERE key='signup_enabled'",
				);
				if (setting.rows[0]?.value !== "true")
					return res.status(403).json({ ok: false, code: "signup_disabled" });
				const password_hash = await bcrypt.hash(password, 12);
				const user = (
					await db.query(
						"INSERT INTO users(email,password_hash) VALUES($1,$2) RETURNING id,email,role",
						[email, password_hash],
					)
				).rows[0];
				const s = await createSession(user, req);
				setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
				await audit(user.id, "signup");
				res.status(201).json({
					ok: true,
					user: { email: user.email, role: user.role },
					csrfToken: s.csrfToken,
				});
			} catch (e) {
				if (e.code === "23505")
					return res.status(409).json({ ok: false, code: "email_exists" });
				next(e);
			}
		},
	);
	app.post(
		"/api/auth/bootstrap",
		rateLimit("bootstrap", 4, 60_000),
		async (req, res, next) => {
			try {
				const exists = await db.query("SELECT 1 FROM users WHERE email=$1", [
					ADMIN_EMAIL,
				]);
				// Once the administrator exists, the deployment no longer needs to retain
				// the bootstrap secret. Do not reveal whether a supplied token was valid.
				if (exists.rowCount)
					return res.status(409).json({ ok: false, code: "bootstrap_used" });
				const bootstrap = String(req.body.setupToken || "");
				if (!env.BOOTSTRAP_SETUP_TOKEN)
					return res
						.status(503)
						.json({ ok: false, code: "bootstrap_unavailable" });
				if (
					bootstrap.length !== env.BOOTSTRAP_SETUP_TOKEN.length ||
					!crypto.timingSafeEqual(
						Buffer.from(bootstrap),
						Buffer.from(env.BOOTSTRAP_SETUP_TOKEN),
					)
				)
					return res
						.status(403)
						.json({ ok: false, code: "invalid_setup_token" });
				if (
					emailOf(req.body.email) !== ADMIN_EMAIL ||
					!PASSWORD_OK(req.body.password)
				)
					return res
						.status(400)
						.json({ ok: false, code: "invalid_credentials" });
				const user = (
					await db.query(
						"INSERT INTO users(email,password_hash,role) VALUES($1,$2,'admin') RETURNING id,email,role",
						[ADMIN_EMAIL, await bcrypt.hash(req.body.password, 12)],
					)
				).rows[0];
				const s = await createSession(user, req);
				setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
				await audit(user.id, "bootstrap_admin");
				res.status(201).json({
					ok: true,
					user: { email: user.email, role: user.role },
					csrfToken: s.csrfToken,
				});
			} catch (e) {
				next(e);
			}
		},
	);
	app.post(
		"/api/auth/login",
		rateLimit("login", 10, 60_000),
		async (req, res, next) => {
			try {
				const email = emailOf(req.body.email);
				const user = (
					await db.query(
						"SELECT id,email,role,password_hash,disabled_at FROM users WHERE email=$1",
						[email],
					)
				).rows[0];
				// Always run bcrypt against something, even for an unknown email, so the
				// response time doesn't reveal whether the account exists; and check the lock
				// only *after* a failed compare, so a correct password always gets the owner in
				// even while other guesses have it locked — the lock throttles guessing, it must
				// never let an attacker who merely knows the address lock the owner out.
				const passwordOk = await bcrypt.compare(
					String(req.body.password || ""),
					user?.password_hash || DUMMY_PASSWORD_HASH,
				);
				if (!user || user.disabled_at || !passwordOk) {
					if (emailLocked(email))
						return res
							.status(429)
							.json({ ok: false, code: "too_many_attempts" });
					recordLoginFailure(email);
					return res
						.status(401)
						.json({ ok: false, code: "invalid_credentials" });
				}
				clearLoginFailures(email);
				const s = await createSession(user, req);
				setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
				await audit(user.id, "login");
				res.json({
					ok: true,
					user: { email: user.email, role: user.role },
					csrfToken: s.csrfToken,
				});
			} catch (e) {
				next(e);
			}
		},
	);
	app.get("/api/auth/me", requireAuth, (req, res) =>
		res.json({
			ok: true,
			user: {
				id: req.user.id,
				email: req.user.email,
				role: req.user.role,
				createdAt: req.user.created_at,
				hasAvatar: Boolean(req.user.has_avatar),
			},
		}),
	);
	// Logout deliberately does not require CSRF: the worst a forged cross-site request can do
	// is log the visitor out, and requiring CSRF here can strand a user with a stale/missing
	// csrf cookie in a state where they can neither use the app nor sign out of it.
	app.post("/api/auth/logout", requireAuth, async (req, res, next) => {
		try {
			await db.query("DELETE FROM sessions WHERE token_hash=$1", [
				hash(cookie(req, "rp_session")),
			]);
			setSessionCookie(res, "", 0, "");
			res.json({ ok: true });
		} catch (e) {
			next(e);
		}
	});
	app.post(
		"/api/auth/forgot",
		rateLimit("forgot", 5, 15 * 60_000),
		async (req, res, next) => {
			try {
				const email = emailOf(req.body.email);
				const user = (
					await db.query("SELECT id,email,role FROM users WHERE email=$1", [
						email,
					])
				).rows[0];
				// Always answer the same way regardless of whether the account exists.
				if (!user) return res.json({ ok: true });
				await db.query(
					"DELETE FROM password_reset_tokens WHERE user_id=$1",
					[user.id],
				);
				const resetToken = token();
				await db.query(
					"INSERT INTO password_reset_tokens(token_hash,user_id,expires_at) VALUES($1,$2,now()+interval '1 hour')",
					[hash(resetToken), user.id],
				);
				const resetUrl = `${origin}/reset?token=${resetToken}`;
				const sent = await sendMail({
					smtpUrl: env.SMTP_URL,
					from: mailFrom,
					to: user.email,
					subject: "Reset your Roast Planner password",
					text: `Reset your password: ${resetUrl}\n\nThis link expires in 1 hour. If you did not request this, ignore this email.`,
				}).catch((e) => {
					console.error("password_reset_email_failed", e);
					return false;
				});
				if (!sent) {
					console.log(`[password reset] ${user.email}: ${resetUrl}`);
					// Admin-account resets are deliberately kept out of the shared admin panel:
					// any other admin could otherwise read the link and take over that account
					// (including the bootstrap admin, which every other guard protects).
					// Recovering an admin without SMTP configured requires server console access.
					if (user.role !== "admin")
						pendingResets.set(hash(resetToken), {
							email: user.email,
							url: resetUrl,
							expiresAt: Date.now() + 60 * 60_000,
						});
				}
				await audit(user.id, "password_reset_requested");
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);
	app.post(
		"/api/auth/reset",
		rateLimit("reset", 10, 15 * 60_000),
		async (req, res, next) => {
			try {
				const rawToken = String(req.body.token || "");
				const password = req.body.password;
				if (!rawToken || !PASSWORD_OK(password))
					return res.status(400).json({ ok: false, code: "invalid_request" });
				const tokenHash = hash(rawToken);
				const row = (
					await db.query(
						"SELECT user_id FROM password_reset_tokens WHERE token_hash=$1 AND expires_at>now()",
						[tokenHash],
					)
				).rows[0];
				if (!row)
					return res.status(400).json({ ok: false, code: "invalid_token" });
				const password_hash = await bcrypt.hash(password, 12);
				await db.query("UPDATE users SET password_hash=$1 WHERE id=$2", [
					password_hash,
					row.user_id,
				]);
				await db.query("DELETE FROM password_reset_tokens WHERE user_id=$1", [
					row.user_id,
				]);
				await db.query("DELETE FROM sessions WHERE user_id=$1", [
					row.user_id,
				]);
				pendingResets.delete(tokenHash);
				const user = (
					await db.query(
						"SELECT id,email,role,disabled_at FROM users WHERE id=$1",
						[row.user_id],
					)
				).rows[0];
				await audit(user.id, "password_reset_completed");
				// The password is changed either way, but a disabled account still can't sign
				// in — say so plainly instead of handing back a session the very next request
				// will reject with a bare 401.
				if (user.disabled_at)
					return res
						.status(403)
						.json({ ok: false, code: "account_disabled" });
				const s = await createSession(user, req);
				setSessionCookie(res, s.raw, 14 * 864e5, s.csrfToken);
				res.json({
					ok: true,
					user: { email: user.email, role: user.role },
					csrfToken: s.csrfToken,
				});
			} catch (e) {
				next(e);
			}
		},
	);

	// ─── Plans ─────────────────────────────────────────────────────────────
	app.get("/api/plans", requireAuth, async (req, res, next) => {
		try {
			res.json({
				ok: true,
				plans: (
					await db.query(
						"SELECT id,plan,created_at,updated_at FROM roast_plans WHERE user_id=$1 ORDER BY updated_at DESC",
						[req.user.id],
					)
				).rows,
			});
		} catch (e) {
			next(e);
		}
	});
	app.post("/api/plans", requireAuth, csrf, async (req, res, next) => {
		try {
			if (!req.body.plan || typeof req.body.plan !== "object")
				return res.status(400).json({ ok: false, code: "bad_plan" });
			const p = (
				await db.query(
					"INSERT INTO roast_plans(user_id,plan) VALUES($1,$2) RETURNING id,plan,created_at,updated_at",
					[req.user.id, req.body.plan],
				)
			).rows[0];
			res.status(201).json({ ok: true, plan: p });
		} catch (e) {
			next(e);
		}
	});
	app.put(
		"/api/plans/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				if (!req.body.plan || typeof req.body.plan !== "object")
					return res.status(400).json({ ok: false, code: "bad_plan" });
				const r = await db.query(
					"UPDATE roast_plans SET plan=$1,updated_at=now() WHERE id=$2 AND user_id=$3 RETURNING id,plan,updated_at",
					[req.body.plan, req.params.id, req.user.id],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				res.json({ ok: true, plan: r.rows[0] });
			} catch (e) {
				next(e);
			}
		},
	);
	app.delete(
		"/api/plans/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const r = await db.query(
					"DELETE FROM roast_plans WHERE id=$1 AND user_id=$2",
					[req.params.id, req.user.id],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);

	// Learned per-user machine profile (pace factor + temperature bands) from this account's own
	// completed roasts — see shared/learn.js. Deliberately reads every one of the user's plans
	// rather than paginating: a personal roast log tops out at low hundreds of rows, and this is
	// the only place that number gets reduced, so there's nothing to cache incrementally against.
	app.get("/api/machine-profile", requireAuth, async (req, res, next) => {
		try {
			const rows = (
				await db.query("SELECT plan FROM roast_plans WHERE user_id=$1", [req.user.id])
			).rows;
			res.json({ ok: true, profile: computeMachineProfile(rows.map((r) => r.plan)) });
		} catch (e) {
			next(e);
		}
	});

	// ─── Inventory ─────────────────────────────────────────────────────────
	// Strict on purpose: JSON.stringify silently turns a client-side NaN (e.g. a non-numeric
	// weight/cost typed into a field that isn't really constrained to digits) into null, which
	// is indistinguishable from "the user explicitly cleared this field" unless the server
	// requires the *type* to be number, not just coerces whatever arrives with Number(). A loose
	// Number(value) here previously let a garbage weight silently become 0 and corrupt
	// remaining_weight_g.
	const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
	function parseOptionalNumber(value, fieldName) {
		if (value === undefined || value === null || value === "") return { value: null };
		if (!isFiniteNumber(value))
			throw new Error(`${fieldName} must be a number`);
		return { value };
	}
	const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
	function parseOptionalDate(value) {
		if (value === undefined || value === null || value === "") return { value: null };
		if (typeof value !== "string" || !DATE_RE.test(value) || Number.isNaN(Date.parse(value)))
			throw new Error("purchaseDate must be a YYYY-MM-DD date");
		return { value };
	}
	const toLotRow = (row) => ({
		id: row.id,
		origin: row.origin,
		variety: row.variety,
		process: row.process,
		producer: row.producer,
		purchaseDate: row.purchase_date,
		initialWeightG: Number(row.initial_weight_g),
		remainingWeightG: Number(row.remaining_weight_g),
		costTotal: row.cost_total == null ? null : Number(row.cost_total),
		moisturePct: row.moisture_pct == null ? null : Number(row.moisture_pct),
		densityGL: row.density_g_l == null ? null : Number(row.density_g_l),
		notes: row.notes,
		archived: row.archived,
		createdAt: row.created_at,
		updatedAt: row.updated_at,
	});
	app.get("/api/inventory", requireAuth, async (req, res, next) => {
		try {
			const rows = (
				await db.query(
					"SELECT * FROM green_bean_lots WHERE user_id=$1 ORDER BY archived ASC, purchase_date DESC NULLS LAST, created_at DESC",
					[req.user.id],
				)
			).rows;
			res.json({ ok: true, lots: rows.map(toLotRow) });
		} catch (e) {
			next(e);
		}
	});
	app.post("/api/inventory", requireAuth, csrf, async (req, res, next) => {
		try {
			const origin = String(req.body.origin || "").trim();
			if (!origin || !isFiniteNumber(req.body.initialWeightG) || req.body.initialWeightG < 0)
				return res.status(400).json({ ok: false, code: "bad_lot" });
			let costTotal, moisturePct, densityGL, purchaseDate;
			try {
				costTotal = parseOptionalNumber(req.body.costTotal, "costTotal");
				moisturePct = parseOptionalNumber(req.body.moisturePct, "moisturePct");
				densityGL = parseOptionalNumber(req.body.densityGL, "densityGL");
				purchaseDate = parseOptionalDate(req.body.purchaseDate);
			} catch (e) {
				return res.status(400).json({ ok: false, code: "bad_lot", error: e.message });
			}
			const row = (
				await db.query(
					`INSERT INTO green_bean_lots
					 (user_id,origin,variety,process,producer,purchase_date,initial_weight_g,remaining_weight_g,cost_total,moisture_pct,density_g_l,notes)
					 VALUES($1,$2,$3,$4,$5,$6,$7,$7,$8,$9,$10,$11) RETURNING *`,
					[
						req.user.id,
						origin,
						String(req.body.variety || ""),
						String(req.body.process || ""),
						String(req.body.producer || ""),
						purchaseDate.value,
						req.body.initialWeightG,
						costTotal.value,
						moisturePct.value,
						densityGL.value,
						String(req.body.notes || ""),
					],
				)
			).rows[0];
			res.status(201).json({ ok: true, lot: toLotRow(row) });
		} catch (e) {
			next(e);
		}
	});
	app.get(
		"/api/inventory/:id",
		requireAuth,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const lot = (
					await db.query(
						"SELECT * FROM green_bean_lots WHERE id=$1 AND user_id=$2",
						[req.params.id, req.user.id],
					)
				).rows[0];
				if (!lot) return res.status(404).json({ ok: false, code: "not_found" });
				const log = (
					await db.query(
						`SELECT c.id,c.weight_g,c.roast_plan_id,c.created_at,p.plan->'fields'->>'0.1' AS plan_title
						 FROM bean_consumption c LEFT JOIN roast_plans p ON p.id=c.roast_plan_id AND p.user_id=c.user_id
						 WHERE c.lot_id=$1 ORDER BY c.created_at DESC`,
						[req.params.id],
					)
				).rows;
				res.json({
					ok: true,
					lot: toLotRow(lot),
					log: log.map((r) => ({
						id: r.id,
						weightG: Number(r.weight_g),
						roastPlanId: r.roast_plan_id,
						planTitle: r.plan_title,
						createdAt: r.created_at,
					})),
				});
			} catch (e) {
				next(e);
			}
		},
	);
	// The auto-refine-carry-forward feature: the most recent past roast against this lot whose
	// "One change next batch" note was actually filled in, so the planner can offer it as a
	// starting ± Refine value instead of the user retyping their own prior conclusion. Reads
	// inventory.lotId straight off the plan JSONB — no join needed, and scoping to this user's own
	// roast_plans is what keeps this ownership-safe regardless of whether :id even belongs to them.
	app.get(
		"/api/inventory/:id/last-refine",
		requireAuth,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const lot = (
					await db.query(
						"SELECT id FROM green_bean_lots WHERE id=$1 AND user_id=$2",
						[req.params.id, req.user.id],
					)
				).rows[0];
				if (!lot) return res.status(404).json({ ok: false, code: "not_found" });
				// Two sources, newest wins: legacy plans that stored the note in the worksheet's
				// afterRoast, and actual roasts (where after-the-roast now lives) linked to a
				// plan against this lot.
				const row = (
					await db.query(
						`SELECT one_change, plan_title, updated_at FROM (
						   SELECT plan->'afterRoast'->>'oneChange' AS one_change,
						     plan->'fields'->>'0.1' AS plan_title, updated_at
						   FROM roast_plans
						   WHERE user_id=$1 AND plan->'inventory'->>'lotId'=$2
						     AND coalesce(plan->'afterRoast'->>'oneChange','')<>''
						   UNION ALL
						   SELECT a.after->>'oneChange' AS one_change,
						     p.plan->'fields'->>'0.1' AS plan_title, a.updated_at
						   FROM actual_roasts a
						   JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
						   WHERE a.user_id=$1 AND p.plan->'inventory'->>'lotId'=$2
						     AND coalesce(a.after->>'oneChange','')<>''
						 ) src ORDER BY updated_at DESC LIMIT 1`,
						[req.user.id, req.params.id],
					)
				).rows[0];
				res.json({
					ok: true,
					refine: row
						? { oneChange: row.one_change, planTitle: row.plan_title, atIso: row.updated_at }
						: null,
				});
			} catch (e) {
				next(e);
			}
		},
	);
	app.put(
		"/api/inventory/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const existing = (
					await db.query(
						"SELECT * FROM green_bean_lots WHERE id=$1 AND user_id=$2",
						[req.params.id, req.user.id],
					)
				).rows[0];
				if (!existing)
					return res.status(404).json({ ok: false, code: "not_found" });
				const b = req.body;
				const origin =
					b.origin === undefined ? existing.origin : String(b.origin || "").trim();
				if (!origin) return res.status(400).json({ ok: false, code: "bad_lot" });
				if (
					b.initialWeightG !== undefined &&
					(!isFiniteNumber(b.initialWeightG) || b.initialWeightG < 0)
				)
					return res.status(400).json({ ok: false, code: "bad_lot" });
				let costTotal, moisturePct, densityGL, purchaseDate;
				try {
					costTotal =
						b.costTotal === undefined
							? existing.cost_total
							: parseOptionalNumber(b.costTotal, "costTotal").value;
					moisturePct =
						b.moisturePct === undefined
							? existing.moisture_pct
							: parseOptionalNumber(b.moisturePct, "moisturePct").value;
					densityGL =
						b.densityGL === undefined
							? existing.density_g_l
							: parseOptionalNumber(b.densityGL, "densityGL").value;
					purchaseDate =
						b.purchaseDate === undefined
							? existing.purchase_date
							: parseOptionalDate(b.purchaseDate).value;
				} catch (e) {
					return res.status(400).json({ ok: false, code: "bad_lot", error: e.message });
				}
				// remainingWeightG/consumptionLog are never read from the request body: remaining
				// only ever moves via consume(), except for the delta below, which is the
				// sanctioned correction path (recount the bag, fix the paperwork). The delta is
				// computed inside the UPDATE itself (against the pre-statement initial_weight_g)
				// rather than from the `existing` row read above, so a concurrent PUT can't lose
				// the other's delta the way a JS-side read-then-write would.
				const row = (
					await db.query(
						`UPDATE green_bean_lots SET
						 origin=$1, variety=$2, process=$3, producer=$4, purchase_date=$5,
						 remaining_weight_g = remaining_weight_g + COALESCE($6::numeric, initial_weight_g) - initial_weight_g,
						 initial_weight_g = COALESCE($6::numeric, initial_weight_g),
						 cost_total=$7, moisture_pct=$8, density_g_l=$9, notes=$10, archived=$11,
						 updated_at=now()
						 WHERE id=$12 AND user_id=$13 RETURNING *`,
						[
							origin,
							b.variety === undefined ? existing.variety : String(b.variety || ""),
							b.process === undefined ? existing.process : String(b.process || ""),
							b.producer === undefined ? existing.producer : String(b.producer || ""),
							purchaseDate,
							b.initialWeightG === undefined ? null : b.initialWeightG,
							costTotal,
							moisturePct,
							densityGL,
							b.notes === undefined ? existing.notes : String(b.notes || ""),
							b.archived === undefined ? existing.archived : Boolean(b.archived),
							req.params.id,
							req.user.id,
						],
					)
				).rows[0];
				res.json({ ok: true, lot: toLotRow(row) });
			} catch (e) {
				next(e);
			}
		},
	);
	app.delete(
		"/api/inventory/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const r = await db.query(
					"DELETE FROM green_bean_lots WHERE id=$1 AND user_id=$2",
					[req.params.id, req.user.id],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);
	app.post(
		"/api/inventory/:id/consume",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const weightG = Number(req.body.weightG);
				if (!Number.isFinite(weightG) || weightG <= 0)
					return res.status(400).json({ ok: false, code: "bad_weight" });
				const lotExists = (
					await db.query(
						"SELECT 1 FROM green_bean_lots WHERE id=$1 AND user_id=$2",
						[req.params.id, req.user.id],
					)
				).rowCount;
				if (!lotExists)
					return res.status(404).json({ ok: false, code: "not_found" });
				let roastPlanId = null;
				if (req.body.roastPlanId) {
					if (!UUID_RE.test(req.body.roastPlanId))
						return res.status(404).json({ ok: false, code: "not_found" });
					const ownsPlan = (
						await db.query(
							"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
							[req.body.roastPlanId, req.user.id],
						)
					).rowCount;
					if (!ownsPlan)
						return res.status(404).json({ ok: false, code: "not_found" });
					roastPlanId = req.body.roastPlanId;
				}
				// The log insert and the balance update must commit together — on separate pool
				// connections a crash or error between them would leave a consumption row logged
				// with no corresponding decrement, and the unique index means that plan could
				// never be retried. See withTransaction's own comment for why plain sequential
				// db.query("BEGIN")/db.query(...) calls do not actually guarantee this.
				let row;
				try {
					row = await withTransaction(async (client) => {
						try {
							await client.query(
								"INSERT INTO bean_consumption(lot_id,user_id,roast_plan_id,weight_g) VALUES($1,$2,$3,$4)",
								[req.params.id, req.user.id, roastPlanId, weightG],
							);
						} catch (e) {
							if (e.code === "23505") {
								const err = new Error("already_consumed");
								err.httpCode = "already_consumed";
								throw err;
							}
							throw e;
						}
						const updated = (
							await client.query(
								"UPDATE green_bean_lots SET remaining_weight_g=remaining_weight_g-$1, updated_at=now() WHERE id=$2 AND user_id=$3 RETURNING *",
								[weightG, req.params.id, req.user.id],
							)
						).rows[0];
						if (!updated) {
							// The lot was deleted by a concurrent request between the ownership
							// check above and here — roll back the log insert too.
							const err = new Error("not_found");
							err.httpCode = "not_found";
							throw err;
						}
						return updated;
					});
				} catch (e) {
					if (e.httpCode === "already_consumed")
						return res.status(409).json({ ok: false, code: "already_consumed" });
					if (e.httpCode === "not_found")
						return res.status(404).json({ ok: false, code: "not_found" });
					throw e;
				}
				res.status(201).json({ ok: true, lot: toLotRow(row) });
			} catch (e) {
				next(e);
			}
		},
	);

	// Admin-chosen LLM model ("provider:id"), empty = auto. Read per call so a change takes
	// effect immediately without restarting the app.
	const llmModelSetting = async () =>
		(
			await db.query("SELECT value FROM app_settings WHERE key='llm_model'")
		).rows[0]?.value || "";

	// ─── Roasters (per-user machines) ──────────────────────────────────────
	// Numeric override keys the user may tweak on a roaster — these replace the corresponding
	// LEARNED values wherever the profile is applied (plan curve fallbacks, pace).
	const OVERRIDE_KEYS = {
		chargeTempC: [0, 400],
		turningPointS: [10, 300],
		turningPointTempC: [0, 400],
		yellowTempC: [0, 400],
		firstCrackTempC: [0, 400],
		dropTempC: [0, 400],
		paceFactor: [0.5, 2],
	};
	function parseOverrides(raw, existing = {}) {
		if (raw === undefined) return { value: existing };
		if (!raw || typeof raw !== "object") return { error: "overrides must be an object" };
		const out = {};
		for (const [key, [lo, hi]] of Object.entries(OVERRIDE_KEYS)) {
			const v = raw[key];
			if (v === undefined || v === null || v === "") continue;
			if (typeof v !== "number" || !Number.isFinite(v) || v < lo || v > hi)
				return { error: `${key} must be a number between ${lo} and ${hi}` };
			out[key] = v;
		}
		return { value: out };
	}
	const toRoasterRow = (row) => ({
		id: row.id,
		name: row.name,
		model: row.model,
		notes: row.notes,
		isDefault: row.is_default,
		overrides: row.overrides ?? {},
		roastCount: row.roast_count == null ? undefined : Number(row.roast_count),
		createdAt: row.created_at,
		updatedAt: row.updated_at,
	});
	app.get("/api/roasters", requireAuth, async (req, res, next) => {
		try {
			const [rows, counts] = await Promise.all([
				db
					.query(
						"SELECT * FROM roasters WHERE user_id=$1 ORDER BY created_at",
						[req.user.id],
					)
					.then((r) => r.rows),
				db
					.query(
						"SELECT roaster_id, COUNT(*) AS roast_count FROM actual_roasts WHERE user_id=$1 AND roaster_id IS NOT NULL GROUP BY roaster_id",
						[req.user.id],
					)
					.then((r) => new Map(r.rows.map((x) => [x.roaster_id, x.roast_count]))),
			]);
			res.json({
				ok: true,
				roasters: rows.map((row) =>
					toRoasterRow({ ...row, roast_count: counts.get(row.id) ?? 0 }),
				),
			});
		} catch (e) {
			next(e);
		}
	});
	app.post("/api/roasters", requireAuth, csrf, async (req, res, next) => {
		try {
			const name = String(req.body.name || "").trim();
			if (!name) return res.status(400).json({ ok: false, code: "bad_roaster" });
			const overrides = parseOverrides(req.body.overrides, {});
			if (overrides.error)
				return res
					.status(400)
					.json({ ok: false, code: "bad_roaster", error: overrides.error });
			// The user's first roaster becomes the default automatically.
			const hasAny = (
				await db.query("SELECT 1 FROM roasters WHERE user_id=$1 LIMIT 1", [req.user.id])
			).rowCount;
			const makeDefault = !hasAny || Boolean(req.body.isDefault);
			if (makeDefault && hasAny)
				await db.query(
					"UPDATE roasters SET is_default=false WHERE user_id=$1",
					[req.user.id],
				);
			const row = (
				await db.query(
					"INSERT INTO roasters(user_id,name,model,notes,is_default,overrides) VALUES($1,$2,$3,$4,$5,$6) RETURNING *",
					[
						req.user.id,
						name,
						String(req.body.model || ""),
						String(req.body.notes || ""),
						makeDefault,
						JSON.stringify(overrides.value),
					],
				)
			).rows[0];
			res.status(201).json({ ok: true, roaster: toRoasterRow(row) });
		} catch (e) {
			next(e);
		}
	});
	app.put(
		"/api/roasters/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const existing = (
					await db.query("SELECT * FROM roasters WHERE id=$1 AND user_id=$2", [
						req.params.id,
						req.user.id,
					])
				).rows[0];
				if (!existing)
					return res.status(404).json({ ok: false, code: "not_found" });
				const b = req.body;
				const name =
					b.name === undefined ? existing.name : String(b.name || "").trim();
				if (!name) return res.status(400).json({ ok: false, code: "bad_roaster" });
				const overrides = parseOverrides(b.overrides, existing.overrides ?? {});
				if (overrides.error)
					return res
						.status(400)
						.json({ ok: false, code: "bad_roaster", error: overrides.error });
				const makeDefault =
					b.isDefault === undefined ? existing.is_default : Boolean(b.isDefault);
				if (makeDefault && !existing.is_default)
					await db.query(
						"UPDATE roasters SET is_default=false WHERE user_id=$1",
						[req.user.id],
					);
				const row = (
					await db.query(
						"UPDATE roasters SET name=$1, model=$2, notes=$3, is_default=$4, overrides=$5, updated_at=now() WHERE id=$6 AND user_id=$7 RETURNING *",
						[
							name,
							b.model === undefined ? existing.model : String(b.model || ""),
							b.notes === undefined ? existing.notes : String(b.notes || ""),
							makeDefault,
							JSON.stringify(overrides.value),
							req.params.id,
							req.user.id,
						],
					)
				).rows[0];
				res.json({ ok: true, roaster: toRoasterRow(row) });
			} catch (e) {
				next(e);
			}
		},
	);
	app.delete(
		"/api/roasters/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const r = await db.query(
					"DELETE FROM roasters WHERE id=$1 AND user_id=$2",
					[req.params.id, req.user.id],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);

	// Learned roaster behavior aggregated from this user's uploaded .alogs — grounds the plan
	// chat, the roast evaluations, and the planner's suggested curve temps. Scoped to one
	// machine: an explicit roasterId, else the user's default roaster, else all uploads
	// (covers pre-roaster history and users with a single unmanaged machine). The returned
	// medians already have that roaster's user overrides applied; the raw learned values ride
	// along so the report page can show learned-vs-applied.
	async function userRoasterProfile(userId, roasterId = null) {
		let roaster = null;
		if (roasterId) {
			roaster = (
				await db.query("SELECT * FROM roasters WHERE id=$1 AND user_id=$2", [
					roasterId,
					userId,
				])
			).rows[0];
			if (!roaster) return null;
		} else {
			roaster =
				(
					await db.query(
						"SELECT * FROM roasters WHERE user_id=$1 AND is_default=true",
						[userId],
					)
				).rows[0] ?? null;
		}
		const rows = roaster
			? (
					await db.query(
						"SELECT parsed FROM actual_roasts WHERE user_id=$1 AND roaster_id=$2",
						[userId, roaster.id],
					)
				).rows
			: (
					await db.query("SELECT parsed FROM actual_roasts WHERE user_id=$1", [userId])
				).rows;
		const learned = computeRoasterProfile(rows.map((r) => r.parsed));
		const overrides = roaster?.overrides ?? {};
		const { paceFactor, ...tempOverrides } = overrides;
		return {
			...learned,
			medians: { ...(learned.medians ?? {}), ...tempOverrides },
			learnedMedians: learned.medians ?? null,
			overrides,
			paceFactorOverride: Number.isFinite(paceFactor) ? paceFactor : null,
			roaster: roaster
				? { id: roaster.id, name: roaster.name, isDefault: roaster.is_default }
				: null,
		};
	}
	app.get("/api/roaster-profile", requireAuth, async (req, res, next) => {
		try {
			const roasterId =
				typeof req.query.roaster === "string" && UUID_RE.test(req.query.roaster)
					? req.query.roaster
					: null;
			const profile = await userRoasterProfile(req.user.id, roasterId);
			if (!profile) return res.status(404).json({ ok: false, code: "not_found" });
			res.json({ ok: true, profile });
		} catch (e) {
			next(e);
		}
	});
	app.post("/api/plan-chat", requireAuth, csrf, async (req, res, next) => {
		try {
			if (!req.body.plan || typeof req.body.plan !== "object")
				return res.status(400).json({ ok: false, code: "bad_plan" });
			let messages;
			try {
				messages = coerceChatMessages(req.body.messages);
			} catch (e) {
				return res.status(400).json({ ok: false, code: "bad_messages", error: e.message });
			}
			const [roasterProfile, planRows, preferredModel] = await Promise.all([
				userRoasterProfile(req.user.id),
				db
					.query("SELECT plan FROM roast_plans WHERE user_id=$1", [req.user.id])
					.then((r) => r.rows.map((row) => row.plan)),
				llmModelSetting(),
			]);
			const result = await runPlanChat({
				plan: req.body.plan,
				messages,
				machineProfile: computeMachineProfile(planRows),
				roasterProfile,
				preferredModel,
			});
			res.json({ ok: true, ...result });
		} catch (err) {
			const code = err.code ?? "chat_failed";
			res
				.status(code === "no_model" ? 503 : 422)
				.json({ ok: false, code, error: err.message });
		}
	});

	// ─── Actual roasts (finished .alog uploads) ────────────────────────────
	const toRoastRow = (row, { full = false } = {}) => {
		const parsed = row.parsed || {};
		const evaluation = row.evaluation || null;
		const base = {
			id: row.id,
			roastPlanId: row.roast_plan_id,
			planTitle: row.plan_title ?? null,
			roasterId: row.roaster_id ?? null,
			roasterName: row.roaster_name ?? null,
			filename: row.filename,
			roast: parsed.roast ?? null,
			derived: parsed.derived ?? null,
			evaluationStatus: row.evaluation_status,
			evaluationError: row.evaluation_error,
			evaluationGrade: evaluation?.grade ?? null,
			evaluationSummary: evaluation?.summary ?? null,
			createdAt: row.created_at,
		};
		return full
			? { ...base, parsed, evaluation, after: row.after ?? {}, plan: row.plan ?? null }
			: base;
	};
	// "After the roast" observations recorded against an actual roast (moved here from the
	// plan worksheet). All free-text/short strings — the numbers among them (weights, DTR)
	// stay strings like the worksheet always stored them.
	const AFTER_FIELDS = [
		"greenIn", "out", "weightLossPct", "vsTarget", "actualDtrPct", "colour",
		"restedDays", "brewRatio", "method", "cupNotes", "oneChange", "disproof",
	];
	function sanitizeAfter(raw) {
		if (!raw || typeof raw !== "object") return null;
		const out = {};
		for (const field of AFTER_FIELDS)
			if (raw[field] !== undefined && raw[field] !== null)
				out[field] = String(raw[field]).slice(0, 2_000);
		return out;
	}
	// Fire-and-forget: the upload response never waits on the model (a deep review takes tens of
	// seconds, and uploads arrive in batches); the row starts 'pending' and the client polls.
	// Failure is recorded on the row rather than lost — 'failed' + evaluation_error, and the
	// re-evaluate endpoint below is the retry path (e.g. once a model is configured).
	function startEvaluation(roastId, userId) {
		const run = (async () => {
			const row = (
				await db.query(
					`SELECT a.parsed, a.roaster_id, p.plan FROM actual_roasts a
					 LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
					 WHERE a.id=$1 AND a.user_id=$2`,
					[roastId, userId],
				)
			).rows[0];
			if (!row) return;
			try {
				const evaluation = await evaluateRoast(
					row.parsed,
					row.plan ?? null,
					await llmModelSetting(),
					await userRoasterProfile(userId, row.roaster_id ?? null),
				);
				await db.query(
					"UPDATE actual_roasts SET evaluation=$1, evaluation_status='done', evaluation_error=NULL, updated_at=now() WHERE id=$2",
					[evaluation, roastId],
				);
			} catch (e) {
				await db.query(
					"UPDATE actual_roasts SET evaluation_status='failed', evaluation_error=$1, updated_at=now() WHERE id=$2",
					[`${e.code ?? "error"}: ${e.message}`.slice(0, 500), roastId],
				);
			}
		})().catch((e) => console.error("roast_evaluation_failed", roastId, e));
		return run;
	}
	app.post("/api/roasts", requireAuth, csrf, async (req, res, next) => {
		try {
			const content = req.body.content;
			if (typeof content !== "string" || !content.trim())
				return res.status(400).json({ ok: false, code: "bad_request" });
			const filename = String(req.body.filename || "upload.alog").slice(0, 200);
			let roastPlanId = null;
			if (req.body.roastPlanId) {
				if (!UUID_RE.test(req.body.roastPlanId))
					return res.status(404).json({ ok: false, code: "not_found" });
				const ownsPlan = (
					await db.query(
						"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
						[req.body.roastPlanId, req.user.id],
					)
				).rowCount;
				if (!ownsPlan)
					return res.status(404).json({ ok: false, code: "not_found" });
				roastPlanId = req.body.roastPlanId;
			}
			let parsed;
			try {
				parsed = parseAlog(content, filename);
			} catch (err) {
				return res
					.status(422)
					.json({ ok: false, code: "unparseable_alog", error: err.message });
			}
			// Attach to the requested roaster (must be the user's own), else the default one.
			let roasterId = null;
			if (req.body.roasterId) {
				if (!UUID_RE.test(req.body.roasterId))
					return res.status(404).json({ ok: false, code: "not_found" });
				const owns = (
					await db.query("SELECT 1 FROM roasters WHERE id=$1 AND user_id=$2", [
						req.body.roasterId,
						req.user.id,
					])
				).rowCount;
				if (!owns) return res.status(404).json({ ok: false, code: "not_found" });
				roasterId = req.body.roasterId;
			} else {
				roasterId =
					(
						await db.query(
							"SELECT id FROM roasters WHERE user_id=$1 AND is_default=true",
							[req.user.id],
						)
					).rows[0]?.id ?? null;
			}
			const row = (
				await db.query(
					`INSERT INTO actual_roasts(user_id,roast_plan_id,roaster_id,filename,original_content,parsed)
					 VALUES($1,$2,$3,$4,$5,$6) RETURNING *`,
					[req.user.id, roastPlanId, roasterId, filename, content, parsed],
				)
			).rows[0];
			startEvaluation(row.id, req.user.id);
			res.status(201).json({ ok: true, roast: toRoastRow(row) });
		} catch (e) {
			next(e);
		}
	});
	app.get("/api/roasts", requireAuth, async (req, res, next) => {
		try {
			const planFilter =
				typeof req.query.plan === "string" && UUID_RE.test(req.query.plan)
					? req.query.plan
					: null;
			const rows = (
				await db.query(
					`SELECT a.id,a.roast_plan_id,a.roaster_id,a.filename,a.parsed,a.evaluation,a.evaluation_status,a.evaluation_error,a.created_at,
					 p.plan->'fields'->>'0.1' AS plan_title, m.name AS roaster_name
					 FROM actual_roasts a
					 LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
					 LEFT JOIN roasters m ON m.id=a.roaster_id AND m.user_id=a.user_id
					 WHERE a.user_id=$1 AND ($2::uuid IS NULL OR a.roast_plan_id=$2)
					 ORDER BY a.created_at DESC`,
					[req.user.id, planFilter],
				)
			).rows;
			res.json({ ok: true, roasts: rows.map((r) => toRoastRow(r)) });
		} catch (e) {
			next(e);
		}
	});
	app.get(
		"/api/roasts/:id",
		requireAuth,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const row = (
					await db.query(
						`SELECT a.*, p.plan->'fields'->>'0.1' AS plan_title, p.plan, m.name AS roaster_name
						 FROM actual_roasts a
						 LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
						 LEFT JOIN roasters m ON m.id=a.roaster_id AND m.user_id=a.user_id
						 WHERE a.id=$1 AND a.user_id=$2`,
						[req.params.id, req.user.id],
					)
				).rows[0];
				if (!row) return res.status(404).json({ ok: false, code: "not_found" });
				res.json({ ok: true, roast: toRoastRow(row, { full: true }) });
			} catch (e) {
				next(e);
			}
		},
	);
	app.get(
		"/api/roasts/:id/download",
		requireAuth,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const row = (
					await db.query(
						`SELECT a.filename, a.original_content, a.after, a.evaluation, a.parsed,
						 p.plan->'fields'->>'0.1' AS plan_title, a.roast_plan_id
						 FROM actual_roasts a
						 LEFT JOIN roast_plans p ON p.id=a.roast_plan_id AND p.user_id=a.user_id
						 WHERE a.id=$1 AND a.user_id=$2`,
						[req.params.id, req.user.id],
					)
				).rows[0];
				if (!row) return res.status(404).json({ ok: false, code: "not_found" });
				let name = row.filename.replace(/[^\w.\- ]+/g, "_").trim() || "roast";
				name = name.replace(/\.alog$/i, "");
				const updated = req.query.variant === "updated";
				let content = row.original_content;
				if (updated) {
					// Supplemental copy: everything this app tracks about the roast written back
					// as valid Artisan fields (Python-literal serialization — Artisan re-imports
					// it with ast.literal_eval). The original stays untouched.
					const after = row.after ?? {};
					const num = (v) => {
						const n = Number.parseFloat(v);
						return Number.isFinite(n) ? n : null;
					};
					const greenIn = num(after.greenIn) ?? row.parsed?.roast?.weightInG ?? null;
					const out = num(after.out) ?? row.parsed?.roast?.weightOutG ?? null;
					const cupping = row.roast_plan_id
						? (
								await db.query(
									"SELECT data,total_score FROM cupping_sessions WHERE roast_plan_id=$1 AND user_id=$2 ORDER BY updated_at DESC LIMIT 1",
									[row.roast_plan_id, req.user.id],
								)
							).rows[0]
						: null;
					const cuppingParts = [
						after.cupNotes,
						cupping ? `Cupping score ${Number(cupping.total_score).toFixed(2)}` : null,
						cupping?.data?.flavor_tags?.length
							? `Flavors: ${cupping.data.flavor_tags.join(", ")}`
							: null,
						after.brewRatio || after.method
							? `Brewed ${[after.method, after.brewRatio].filter(Boolean).join(" ")}`
							: null,
						after.restedDays ? `Rested ${after.restedDays} days` : null,
					].filter(Boolean);
					const roastingParts = [
						after.oneChange ? `One change next batch: ${after.oneChange}` : null,
						after.disproof ? `Would disprove it: ${after.disproof}` : null,
						after.colour ? `Colour ${after.colour}` : null,
						after.actualDtrPct ? `Actual DTR ${after.actualDtrPct}%` : null,
						row.evaluation?.summary ? `LLM review: ${row.evaluation.summary}` : null,
					].filter(Boolean);
					content = buildUpdatedAlog(row.original_content, {
						weight:
							greenIn !== null && out !== null ? [greenIn, out, "g"] : undefined,
						beans: row.plan_title || undefined,
						cuppingnotes: cuppingParts.length ? cuppingParts.join("\n") : undefined,
						roastingnotes: roastingParts.length ? roastingParts.join("\n") : undefined,
					});
				}
				res.set({
					"Content-Type": "application/octet-stream",
					"Content-Disposition": `attachment; filename="${name}${updated ? "-updated" : ""}.alog"`,
				});
				res.send(content);
			} catch (e) {
				next(e);
			}
		},
	);
	// Mutable parts of an upload: which machine it belongs to, and the after-the-roast
	// observations. The file and its parse are immutable history.
	app.put(
		"/api/roasts/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const sets = [];
				const params = [];
				if (req.body.roasterId !== undefined) {
					let roasterId = null;
					if (req.body.roasterId !== null && req.body.roasterId !== "") {
						if (!UUID_RE.test(req.body.roasterId))
							return res.status(404).json({ ok: false, code: "not_found" });
						const owns = (
							await db.query(
								"SELECT 1 FROM roasters WHERE id=$1 AND user_id=$2",
								[req.body.roasterId, req.user.id],
							)
						).rowCount;
						if (!owns)
							return res.status(404).json({ ok: false, code: "not_found" });
						roasterId = req.body.roasterId;
					}
					params.push(roasterId);
					sets.push(`roaster_id=$${params.length}`);
				}
				if (req.body.roastPlanId !== undefined) {
					let roastPlanId = null;
					if (req.body.roastPlanId !== null && req.body.roastPlanId !== "") {
						if (!UUID_RE.test(req.body.roastPlanId))
							return res.status(404).json({ ok: false, code: "not_found" });
						const owns = (
							await db.query(
								"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
								[req.body.roastPlanId, req.user.id],
							)
						).rowCount;
						if (!owns)
							return res.status(404).json({ ok: false, code: "not_found" });
						roastPlanId = req.body.roastPlanId;
					}
					params.push(roastPlanId);
					sets.push(`roast_plan_id=$${params.length}`);
				}
				if (req.body.after !== undefined) {
					const after = sanitizeAfter(req.body.after);
					if (!after)
						return res.status(400).json({ ok: false, code: "bad_after" });
					params.push(after);
					sets.push(`after=$${params.length}`);
				}
				if (!sets.length)
					return res.status(400).json({ ok: false, code: "bad_request" });
				params.push(req.params.id, req.user.id);
				const r = await db.query(
					`UPDATE actual_roasts SET ${sets.join(", ")}, updated_at=now() WHERE id=$${params.length - 1} AND user_id=$${params.length} RETURNING roaster_id, roast_plan_id, after`,
					params,
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				res.json({
					ok: true,
					roasterId: r.rows[0].roaster_id,
					roastPlanId: r.rows[0].roast_plan_id,
					after: r.rows[0].after,
				});
			} catch (e) {
				next(e);
			}
		},
	);
	app.post(
		"/api/roasts/:id/evaluate",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const r = await db.query(
					"UPDATE actual_roasts SET evaluation_status='pending', evaluation_error=NULL, updated_at=now() WHERE id=$1 AND user_id=$2",
					[req.params.id, req.user.id],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				startEvaluation(req.params.id, req.user.id);
				res.json({ ok: true, evaluationStatus: "pending" });
			} catch (e) {
				next(e);
			}
		},
	);
	app.delete(
		"/api/roasts/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const r = await db.query(
					"DELETE FROM actual_roasts WHERE id=$1 AND user_id=$2",
					[req.params.id, req.user.id],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);

	// ─── Roasted beans (brewing-side bean management) ──────────────────────
	// Remaining weight is DERIVED (initial − Σ brew doses), never stored, so concurrent brew
	// logging can't corrupt it and deleting a brew automatically "returns" its dose.
	const BEAN_TEXT_FIELDS = [
		"roaster",
		"origin",
		"process",
		"variety",
		"roastLevel",
		"url",
		"tastingNotes",
		"notes",
	];
	const BEAN_COLUMN = {
		roaster: "roaster",
		origin: "origin",
		process: "process",
		variety: "variety",
		roastLevel: "roast_level",
		url: "url",
		tastingNotes: "tasting_notes",
		notes: "notes",
	};
	const toBeanRow = (row) => ({
		id: row.id,
		name: row.name,
		roaster: row.roaster,
		origin: row.origin,
		process: row.process,
		variety: row.variety,
		roastLevel: row.roast_level,
		roastDate: row.roast_date,
		initialWeightG: row.initial_weight_g == null ? null : Number(row.initial_weight_g),
		remainingWeightG:
			row.initial_weight_g == null
				? null
				: Number(row.initial_weight_g) - Number(row.used_g ?? 0),
		url: row.url,
		tastingNotes: row.tasting_notes,
		notes: row.notes,
		roastPlanId: row.roast_plan_id,
		archived: row.archived,
		brewCount: row.brew_count == null ? undefined : Number(row.brew_count),
		createdAt: row.created_at,
		updatedAt: row.updated_at,
	});
	// Two plain queries merged in JS (not a correlated subquery/GROUP BY b.*) so the identical
	// SQL runs on both real PostgreSQL and the pg-mem test database.
	async function beanUsage(userId) {
		const rows = (
			await db.query(
				"SELECT bean_id, SUM(dose_g) AS used_g, COUNT(*) AS brew_count FROM brews WHERE user_id=$1 AND bean_id IS NOT NULL GROUP BY bean_id",
				[userId],
			)
		).rows;
		return new Map(rows.map((r) => [r.bean_id, r]));
	}
	app.get("/api/beans", requireAuth, async (req, res, next) => {
		try {
			const [rows, usage] = await Promise.all([
				db
					.query(
						"SELECT * FROM roasted_beans WHERE user_id=$1 ORDER BY archived ASC, created_at DESC",
						[req.user.id],
					)
					.then((r) => r.rows),
				beanUsage(req.user.id),
			]);
			res.json({
				ok: true,
				beans: rows.map((row) => toBeanRow({ ...row, ...usage.get(row.id) })),
			});
		} catch (e) {
			next(e);
		}
	});
	app.post("/api/beans", requireAuth, csrf, async (req, res, next) => {
		try {
			const name = String(req.body.name || "").trim();
			if (!name) return res.status(400).json({ ok: false, code: "bad_bean" });
			let initialWeightG, roastDate;
			try {
				initialWeightG = parseOptionalNumber(req.body.initialWeightG, "initialWeightG").value;
				roastDate = parseOptionalDate(req.body.roastDate).value;
			} catch (e) {
				return res.status(400).json({ ok: false, code: "bad_bean", error: e.message });
			}
			let roastPlanId = null;
			if (req.body.roastPlanId) {
				if (!UUID_RE.test(req.body.roastPlanId))
					return res.status(404).json({ ok: false, code: "not_found" });
				const owns = (
					await db.query("SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2", [
						req.body.roastPlanId,
						req.user.id,
					])
				).rowCount;
				if (!owns) return res.status(404).json({ ok: false, code: "not_found" });
				roastPlanId = req.body.roastPlanId;
			}
			const row = (
				await db.query(
					`INSERT INTO roasted_beans
					 (user_id,name,roaster,origin,process,variety,roast_level,roast_date,initial_weight_g,url,tasting_notes,notes,roast_plan_id)
					 VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING *`,
					[
						req.user.id,
						name,
						...BEAN_TEXT_FIELDS.slice(0, 4).map((f) => String(req.body[f] || "")),
						String(req.body.roastLevel || ""),
						roastDate,
						initialWeightG,
						String(req.body.url || ""),
						String(req.body.tastingNotes || ""),
						String(req.body.notes || ""),
						roastPlanId,
					],
				)
			).rows[0];
			res.status(201).json({ ok: true, bean: toBeanRow(row) });
		} catch (e) {
			next(e);
		}
	});
	app.put(
		"/api/beans/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const existing = (
					await db.query(
						"SELECT * FROM roasted_beans WHERE id=$1 AND user_id=$2",
						[req.params.id, req.user.id],
					)
				).rows[0];
				if (!existing)
					return res.status(404).json({ ok: false, code: "not_found" });
				const b = req.body;
				const name =
					b.name === undefined ? existing.name : String(b.name || "").trim();
				if (!name) return res.status(400).json({ ok: false, code: "bad_bean" });
				let initialWeightG, roastDate;
				try {
					initialWeightG =
						b.initialWeightG === undefined
							? existing.initial_weight_g
							: parseOptionalNumber(b.initialWeightG, "initialWeightG").value;
					roastDate =
						b.roastDate === undefined
							? existing.roast_date
							: parseOptionalDate(b.roastDate).value;
				} catch (e) {
					return res.status(400).json({ ok: false, code: "bad_bean", error: e.message });
				}
				const text = {};
				for (const f of BEAN_TEXT_FIELDS)
					text[f] =
						b[f] === undefined ? existing[BEAN_COLUMN[f]] : String(b[f] || "");
				const row = (
					await db.query(
						`UPDATE roasted_beans SET
						 name=$1, roaster=$2, origin=$3, process=$4, variety=$5, roast_level=$6,
						 roast_date=$7, initial_weight_g=$8, url=$9, tasting_notes=$10, notes=$11,
						 archived=$12, updated_at=now()
						 WHERE id=$13 AND user_id=$14 RETURNING *`,
						[
							name,
							text.roaster,
							text.origin,
							text.process,
							text.variety,
							text.roastLevel,
							roastDate,
							initialWeightG,
							text.url,
							text.tastingNotes,
							text.notes,
							b.archived === undefined ? existing.archived : Boolean(b.archived),
							req.params.id,
							req.user.id,
						],
					)
				).rows[0];
				res.json({ ok: true, bean: toBeanRow(row) });
			} catch (e) {
				next(e);
			}
		},
	);
	app.delete(
		"/api/beans/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const r = await db.query(
					"DELETE FROM roasted_beans WHERE id=$1 AND user_id=$2",
					[req.params.id, req.user.id],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);

	// ─── Brews ─────────────────────────────────────────────────────────────
	const BREW_METHOD_KEYS = new Set(BREW_METHODS.map((m) => m.key));
	const toBrewRow = (row) => ({
		id: row.id,
		beanId: row.bean_id,
		beanName: row.bean_name ?? null,
		method: row.method,
		doseG: row.dose_g == null ? null : Number(row.dose_g),
		waterG: row.water_g == null ? null : Number(row.water_g),
		yieldG: row.yield_g == null ? null : Number(row.yield_g),
		grinder: row.grinder,
		grindSetting: row.grind_setting,
		waterTempC: row.water_temp_c == null ? null : Number(row.water_temp_c),
		brewTimeS: row.brew_time_s,
		bloomTimeS: row.bloom_time_s,
		rating: row.rating == null ? null : Number(row.rating),
		recipe: row.recipe,
		tastingNotes: row.tasting_notes,
		notes: row.notes,
		brewedAt: row.brewed_at,
		createdAt: row.created_at,
		updatedAt: row.updated_at,
	});
	/** Shared validation for create/update; returns {error} or the normalized values. */
	function parseBrewBody(b, existing = null) {
		const method = b.method === undefined ? existing?.method : String(b.method || "");
		if (!method || !BREW_METHOD_KEYS.has(method)) return { error: "unknown brew method" };
		const values = { method };
		try {
			for (const [field, name] of [
				["doseG", "dose_g"],
				["waterG", "water_g"],
				["yieldG", "yield_g"],
				["waterTempC", "water_temp_c"],
				["rating", "rating"],
			])
				values[name] =
					b[field] === undefined
						? (existing?.[name] ?? null)
						: parseOptionalNumber(b[field], field).value;
		} catch (e) {
			return { error: e.message };
		}
		if (values.rating !== null && (values.rating < 0 || values.rating > 10))
			return { error: "rating must be 0-10" };
		for (const [field, name] of [
			["brewTimeS", "brew_time_s"],
			["bloomTimeS", "bloom_time_s"],
		]) {
			const v = b[field] === undefined ? (existing?.[name] ?? null) : b[field];
			if (v === null || v === undefined || v === "") values[name] = null;
			else if (!Number.isInteger(v) || v < 0 || v > 86_400)
				return { error: `${field} must be a whole number of seconds` };
			else values[name] = v;
		}
		for (const [field, name] of [
			["grinder", "grinder"],
			["grindSetting", "grind_setting"],
			["recipe", "recipe"],
			["tastingNotes", "tasting_notes"],
			["notes", "notes"],
		])
			values[name] =
				b[field] === undefined ? (existing?.[name] ?? "") : String(b[field] || "");
		return { values };
	}
	app.get("/api/brews", requireAuth, async (req, res, next) => {
		try {
			const beanFilter =
				typeof req.query.bean === "string" && UUID_RE.test(req.query.bean)
					? req.query.bean
					: null;
			const rows = (
				await db.query(
					`SELECT w.*, b.name AS bean_name FROM brews w
					 LEFT JOIN roasted_beans b ON b.id=w.bean_id AND b.user_id=w.user_id
					 WHERE w.user_id=$1 AND ($2::uuid IS NULL OR w.bean_id=$2)
					 ORDER BY w.brewed_at DESC`,
					[req.user.id, beanFilter],
				)
			).rows;
			res.json({ ok: true, brews: rows.map(toBrewRow) });
		} catch (e) {
			next(e);
		}
	});
	app.post("/api/brews", requireAuth, csrf, async (req, res, next) => {
		try {
			let beanId = null;
			if (req.body.beanId) {
				if (!UUID_RE.test(req.body.beanId))
					return res.status(404).json({ ok: false, code: "not_found" });
				const owns = (
					await db.query("SELECT 1 FROM roasted_beans WHERE id=$1 AND user_id=$2", [
						req.body.beanId,
						req.user.id,
					])
				).rowCount;
				if (!owns) return res.status(404).json({ ok: false, code: "not_found" });
				beanId = req.body.beanId;
			}
			const parsedBody = parseBrewBody(req.body);
			if (parsedBody.error)
				return res
					.status(400)
					.json({ ok: false, code: "bad_brew", error: parsedBody.error });
			const v = parsedBody.values;
			const row = (
				await db.query(
					`INSERT INTO brews
					 (user_id,bean_id,method,dose_g,water_g,yield_g,grinder,grind_setting,water_temp_c,brew_time_s,bloom_time_s,rating,recipe,tasting_notes,notes)
					 VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
					[
						req.user.id,
						beanId,
						v.method,
						v.dose_g,
						v.water_g,
						v.yield_g,
						v.grinder,
						v.grind_setting,
						v.water_temp_c,
						v.brew_time_s,
						v.bloom_time_s,
						v.rating,
						v.recipe,
						v.tasting_notes,
						v.notes,
					],
				)
			).rows[0];
			res.status(201).json({ ok: true, brew: toBrewRow(row) });
		} catch (e) {
			next(e);
		}
	});
	app.put(
		"/api/brews/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const existing = (
					await db.query("SELECT * FROM brews WHERE id=$1 AND user_id=$2", [
						req.params.id,
						req.user.id,
					])
				).rows[0];
				if (!existing)
					return res.status(404).json({ ok: false, code: "not_found" });
				let beanId = existing.bean_id;
				if (req.body.beanId !== undefined) {
					if (req.body.beanId === null || req.body.beanId === "") beanId = null;
					else {
						if (!UUID_RE.test(req.body.beanId))
							return res.status(404).json({ ok: false, code: "not_found" });
						const owns = (
							await db.query(
								"SELECT 1 FROM roasted_beans WHERE id=$1 AND user_id=$2",
								[req.body.beanId, req.user.id],
							)
						).rowCount;
						if (!owns)
							return res.status(404).json({ ok: false, code: "not_found" });
						beanId = req.body.beanId;
					}
				}
				const parsedBody = parseBrewBody(req.body, existing);
				if (parsedBody.error)
					return res
						.status(400)
						.json({ ok: false, code: "bad_brew", error: parsedBody.error });
				const v = parsedBody.values;
				const row = (
					await db.query(
						`UPDATE brews SET bean_id=$1, method=$2, dose_g=$3, water_g=$4, yield_g=$5,
						 grinder=$6, grind_setting=$7, water_temp_c=$8, brew_time_s=$9, bloom_time_s=$10,
						 rating=$11, recipe=$12, tasting_notes=$13, notes=$14, updated_at=now()
						 WHERE id=$15 AND user_id=$16 RETURNING *`,
						[
							beanId,
							v.method,
							v.dose_g,
							v.water_g,
							v.yield_g,
							v.grinder,
							v.grind_setting,
							v.water_temp_c,
							v.brew_time_s,
							v.bloom_time_s,
							v.rating,
							v.recipe,
							v.tasting_notes,
							v.notes,
							req.params.id,
							req.user.id,
						],
					)
				).rows[0];
				res.json({ ok: true, brew: toBrewRow(row) });
			} catch (e) {
				next(e);
			}
		},
	);
	app.delete(
		"/api/brews/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const r = await db.query(
					"DELETE FROM brews WHERE id=$1 AND user_id=$2",
					[req.params.id, req.user.id],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);

	// ─── API tokens ────────────────────────────────────────────────────────
	app.get("/api/tokens", requireAuth, async (req, res, next) => {
		try {
			const rows = (
				await db.query(
					"SELECT token_hash,name,created_at,last_used_at FROM api_tokens WHERE user_id=$1 ORDER BY created_at DESC",
					[req.user.id],
				)
			).rows;
			res.json({
				ok: true,
				tokens: rows.map((row) => ({
					id: row.token_hash.slice(0, 16),
					name: row.name,
					createdAt: row.created_at,
					lastUsedAt: row.last_used_at,
				})),
			});
		} catch (e) {
			next(e);
		}
	});
	app.post("/api/tokens", requireAuth, csrf, async (req, res, next) => {
		try {
			const name = String(req.body.name || "").slice(0, 100);
			const count = (
				await db.query(
					"SELECT count(*)::int AS count FROM api_tokens WHERE user_id=$1",
					[req.user.id],
				)
			).rows[0].count;
			if (count >= 20)
				return res.status(409).json({ ok: false, code: "too_many_tokens" });
			// "rpt_" marks the string as a Roast Planner token in secret scanners and logs.
			const raw = `rpt_${token()}`;
			await db.query(
				"INSERT INTO api_tokens(token_hash,user_id,name) VALUES($1,$2,$3)",
				[hash(raw), req.user.id, name],
			);
			await audit(req.user.id, "api_token_created", name || null);
			// The raw token is shown exactly once and never stored.
			res.status(201).json({
				ok: true,
				token: raw,
				id: hash(raw).slice(0, 16),
				name,
			});
		} catch (e) {
			next(e);
		}
	});
	app.delete("/api/tokens/:id", requireAuth, csrf, async (req, res, next) => {
		try {
			const id = String(req.params.id || "");
			if (!/^[0-9a-f]{16}$/i.test(id))
				return res.status(404).json({ ok: false, code: "not_found" });
			const r = await db.query(
				"DELETE FROM api_tokens WHERE user_id=$1 AND token_hash LIKE $2",
				[req.user.id, `${id}%`],
			);
			if (!r.rowCount)
				return res.status(404).json({ ok: false, code: "not_found" });
			await audit(req.user.id, "api_token_revoked");
			res.json({ ok: true });
		} catch (e) {
			next(e);
		}
	});

	// ─── Cupping ───────────────────────────────────────────────────────────
	const toSessionRow = (row, { full = false } = {}) => {
		const data = row.data || {};
		const base = {
			id: row.id,
			roastPlanId: row.roast_plan_id,
			planTitle: row.plan_title ?? undefined,
			cupCount: data.cup_count,
			totalScore: Number(row.total_score),
			flavorTags: data.flavor_tags || [],
			createdAt: row.created_at,
			updatedAt: row.updated_at,
		};
		if (row.plan_title === undefined) delete base.planTitle;
		return full ? { ...base, data } : base;
	};
	app.get("/api/cupping", requireAuth, async (req, res, next) => {
		try {
			const planFilter =
				typeof req.query.plan === "string" && UUID_RE.test(req.query.plan)
					? req.query.plan
					: null;
			const rows = (
				await db.query(
					`SELECT s.id,s.roast_plan_id,s.data,s.total_score,s.created_at,s.updated_at,
					 p.plan->'fields'->>'0.1' AS plan_title
					 FROM cupping_sessions s LEFT JOIN roast_plans p ON p.id=s.roast_plan_id AND p.user_id=s.user_id
					 WHERE s.user_id=$1 AND ($2::uuid IS NULL OR s.roast_plan_id=$2)
					 ORDER BY s.updated_at DESC`,
					[req.user.id, planFilter],
				)
			).rows;
			res.json({ ok: true, sessions: rows.map((r) => toSessionRow(r)) });
		} catch (e) {
			next(e);
		}
	});
	app.post("/api/cupping", requireAuth, csrf, async (req, res, next) => {
		try {
			let roastPlanId = null;
			if (req.body.roastPlanId) {
				if (!UUID_RE.test(req.body.roastPlanId))
					return res.status(404).json({ ok: false, code: "not_found" });
				const ownsPlan = (
					await db.query(
						"SELECT 1 FROM roast_plans WHERE id=$1 AND user_id=$2",
						[req.body.roastPlanId, req.user.id],
					)
				).rowCount;
				if (!ownsPlan)
					return res.status(404).json({ ok: false, code: "not_found" });
				roastPlanId = req.body.roastPlanId;
			}
			const cupCount = req.body.cupCount == null ? undefined : Number(req.body.cupCount);
			let data;
			try {
				data = coerceSession(blankSession(Number.isFinite(cupCount) ? cupCount : undefined));
			} catch (e) {
				return res.status(400).json({ ok: false, code: "bad_session", error: e.message });
			}
			const row = (
				await db.query(
					"INSERT INTO cupping_sessions(user_id,roast_plan_id,data,total_score) VALUES($1,$2,$3,0) RETURNING *",
					[req.user.id, roastPlanId, data],
				)
			).rows[0];
			res.status(201).json({ ok: true, session: toSessionRow(row, { full: true }) });
		} catch (e) {
			next(e);
		}
	});
	app.get(
		"/api/cupping/:id",
		requireAuth,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const row = (
					await db.query(
						`SELECT s.*, p.plan->'fields'->>'0.1' AS plan_title
						 FROM cupping_sessions s LEFT JOIN roast_plans p ON p.id=s.roast_plan_id AND p.user_id=s.user_id
						 WHERE s.id=$1 AND s.user_id=$2`,
						[req.params.id, req.user.id],
					)
				).rows[0];
				if (!row) return res.status(404).json({ ok: false, code: "not_found" });
				res.json({ ok: true, session: toSessionRow(row, { full: true }) });
			} catch (e) {
				next(e);
			}
		},
	);
	app.put(
		"/api/cupping/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				if (!req.body.data || typeof req.body.data !== "object")
					return res.status(400).json({ ok: false, code: "bad_session" });
				let coerced;
				try {
					coerced = coerceSession(req.body.data);
				} catch (e) {
					return res
						.status(400)
						.json({ ok: false, code: "bad_session", error: e.message });
				}
				const total = computeTotalScore(
					coerced.scores,
					coerced.ticks,
					coerced.taint_cups,
					coerced.fault_cups,
					coerced.cup_count,
				);
				const row = (
					await db.query(
						"UPDATE cupping_sessions SET data=$1, total_score=$2, updated_at=now() WHERE id=$3 AND user_id=$4 RETURNING *",
						[coerced, total, req.params.id, req.user.id],
					)
				).rows[0];
				if (!row) return res.status(404).json({ ok: false, code: "not_found" });
				res.json({ ok: true, session: toSessionRow(row, { full: true }) });
			} catch (e) {
				next(e);
			}
		},
	);
	app.delete(
		"/api/cupping/:id",
		requireAuth,
		csrf,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				const r = await db.query(
					"DELETE FROM cupping_sessions WHERE id=$1 AND user_id=$2",
					[req.params.id, req.user.id],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);

	// ─── Account ───────────────────────────────────────────────────────────
	app.put("/api/account/email", requireAuth, csrf, async (req, res, next) => {
		try {
			const email = emailOf(req.body.email);
			if (!/^\S+@\S+\.\S+$/.test(email))
				return res.status(400).json({ ok: false, code: "invalid_email" });
			// The bootstrap admin's email is fixed: they can't change away from it (which would
			// strip the protection every other guard relies on), and nobody else can claim it
			// (which would falsely grant that same protection to an unrelated account).
			if (email === ADMIN_EMAIL || (await isBootstrapAdmin(req.user.id)))
				return res.status(403).json({ ok: false, code: "forbidden" });
			const user = (
				await db.query("SELECT password_hash FROM users WHERE id=$1", [
					req.user.id,
				])
			).rows[0];
			if (!(await bcrypt.compare(String(req.body.password || ""), user.password_hash)))
				return res.status(401).json({ ok: false, code: "invalid_credentials" });
			await db.query("UPDATE users SET email=$1 WHERE id=$2", [
				email,
				req.user.id,
			]);
			await audit(req.user.id, "email_changed", email);
			res.json({ ok: true, user: { email, role: req.user.role } });
		} catch (e) {
			if (e.code === "23505")
				return res.status(409).json({ ok: false, code: "email_exists" });
			next(e);
		}
	});
	app.put(
		"/api/account/password",
		requireAuth,
		csrf,
		async (req, res, next) => {
			try {
				if (!PASSWORD_OK(req.body.newPassword))
					return res.status(400).json({ ok: false, code: "invalid_password" });
				const user = (
					await db.query("SELECT password_hash FROM users WHERE id=$1", [
						req.user.id,
					])
				).rows[0];
				if (
					!(await bcrypt.compare(
						String(req.body.currentPassword || ""),
						user.password_hash,
					))
				)
					return res
						.status(401)
						.json({ ok: false, code: "invalid_credentials" });
				const password_hash = await bcrypt.hash(req.body.newPassword, 12);
				await db.query("UPDATE users SET password_hash=$1 WHERE id=$2", [
					password_hash,
					req.user.id,
				]);
				// Keep only the session that just proved the current password.
				await db.query(
					"DELETE FROM sessions WHERE user_id=$1 AND token_hash<>$2",
					[req.user.id, hash(cookie(req, "rp_session"))],
				);
				await audit(req.user.id, "password_changed");
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);
	// Profile picture: a small data URL, resized client-side before upload. Served back as a
	// real image so <img>/background-image can use it without shipping base64 in every page.
	const AVATAR_RE = /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/=]+)$/;
	app.put("/api/account/avatar", requireAuth, csrf, async (req, res, next) => {
		try {
			const dataUrl = req.body.dataUrl;
			if (
				typeof dataUrl !== "string" ||
				dataUrl.length > 300_000 ||
				!AVATAR_RE.test(dataUrl)
			)
				return res.status(400).json({ ok: false, code: "bad_avatar" });
			await db.query("UPDATE users SET avatar=$1 WHERE id=$2", [
				dataUrl,
				req.user.id,
			]);
			res.json({ ok: true });
		} catch (e) {
			next(e);
		}
	});
	app.delete("/api/account/avatar", requireAuth, csrf, async (req, res, next) => {
		try {
			await db.query("UPDATE users SET avatar=NULL WHERE id=$1", [req.user.id]);
			res.json({ ok: true });
		} catch (e) {
			next(e);
		}
	});
	app.get("/api/account/avatar", requireAuth, async (req, res, next) => {
		try {
			const avatar = (
				await db.query("SELECT avatar FROM users WHERE id=$1", [req.user.id])
			).rows[0]?.avatar;
			const match = avatar ? avatar.match(AVATAR_RE) : null;
			if (!match) return res.status(404).json({ ok: false, code: "not_found" });
			res.set({
				"Content-Type": `image/${match[1]}`,
				"Cache-Control": "private, max-age=300",
			});
			res.send(Buffer.from(match[2], "base64"));
		} catch (e) {
			next(e);
		}
	});
	app.get("/api/account/sessions", requireAuth, async (req, res, next) => {
		try {
			const currentHash = hash(cookie(req, "rp_session"));
			const rows = (
				await db.query(
					"SELECT token_hash,user_agent,ip,created_at,last_seen_at,expires_at FROM sessions WHERE user_id=$1 ORDER BY COALESCE(last_seen_at,created_at) DESC",
					[req.user.id],
				)
			).rows;
			res.json({
				ok: true,
				sessions: rows.map((row) => ({
					id: row.token_hash.slice(0, 16),
					userAgent: row.user_agent,
					ip: row.ip,
					createdAt: row.created_at,
					lastSeenAt: row.last_seen_at,
					expiresAt: row.expires_at,
					current: row.token_hash === currentHash,
				})),
			});
		} catch (e) {
			next(e);
		}
	});
	app.delete(
		"/api/account/sessions/:id",
		requireAuth,
		csrf,
		async (req, res, next) => {
			try {
				const id = String(req.params.id || "");
				if (!/^[0-9a-f]{16}$/i.test(id))
					return res.status(404).json({ ok: false, code: "not_found" });
				const r = await db.query(
					"DELETE FROM sessions WHERE user_id=$1 AND token_hash LIKE $2",
					[req.user.id, `${id}%`],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);
	app.post(
		"/api/account/sessions/revoke-others",
		requireAuth,
		csrf,
		async (req, res, next) => {
			try {
				await db.query(
					"DELETE FROM sessions WHERE user_id=$1 AND token_hash<>$2",
					[req.user.id, hash(cookie(req, "rp_session"))],
				);
				await audit(req.user.id, "sessions_revoked_others");
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);
	app.delete("/api/account", requireAuth, csrf, async (req, res, next) => {
		try {
			const user = (
				await db.query("SELECT password_hash,role FROM users WHERE id=$1", [
					req.user.id,
				])
			).rows[0];
			if (!(await bcrypt.compare(String(req.body.password || ""), user.password_hash)))
				return res.status(401).json({ ok: false, code: "invalid_credentials" });
			// The bootstrap admin can never remove themselves — deleting that account would
			// re-open /setup to anyone still holding the deployment's bootstrap token.
			if (await isBootstrapAdmin(req.user.id))
				return res.status(403).json({ ok: false, code: "forbidden" });
			if (user.role === "admin") {
				const admins = await db.query(
					"SELECT count(*)::int AS count FROM users WHERE role='admin'",
				);
				if (admins.rows[0].count <= 1)
					return res.status(409).json({ ok: false, code: "last_admin" });
			}
			await audit(req.user.id, "account_self_deleted");
			await db.query("DELETE FROM users WHERE id=$1", [req.user.id]);
			setSessionCookie(res, "", 0, "");
			res.json({ ok: true });
		} catch (e) {
			next(e);
		}
	});

	// ─── Admin ─────────────────────────────────────────────────────────────
	app.get("/api/admin/users", requireAuth, admin, async (req, res, next) => {
		try {
			res.json({
				ok: true,
				signupEnabled:
					(
						await db.query(
							"SELECT value FROM app_settings WHERE key='signup_enabled'",
						)
					).rows[0]?.value === "true",
				users: (
					await db.query(
						"SELECT u.id,u.email,u.role,u.created_at,u.disabled_at,count(p.id)::int AS plan_count FROM users u LEFT JOIN roast_plans p ON p.user_id=u.id GROUP BY u.id,u.email,u.role,u.created_at,u.disabled_at ORDER BY u.created_at",
					)
				).rows,
			});
		} catch (e) {
			next(e);
		}
	});
	app.get("/api/admin/metrics", requireAuth, admin, async (req, res, next) => {
		try {
			const [users, plans, recentPlans, sessions] = await Promise.all([
				db.query("SELECT count(*)::int AS count FROM users"),
				db.query("SELECT count(*)::int AS count FROM roast_plans"),
				db.query(
					"SELECT count(*)::int AS count FROM roast_plans WHERE updated_at > now() - interval '7 days'",
				),
				db.query(
					"SELECT count(*)::int AS count FROM sessions WHERE expires_at > now()",
				),
			]);
			res.json({
				ok: true,
				metrics: {
					totalUsers: users.rows[0].count,
					totalPlans: plans.rows[0].count,
					plansUpdatedLast7Days: recentPlans.rows[0].count,
					activeSessions: sessions.rows[0].count,
				},
			});
		} catch (e) {
			next(e);
		}
	});
	app.get("/api/admin/plans", requireAuth, admin, async (req, res, next) => {
		try {
			const userFilter =
				typeof req.query.user === "string" && UUID_RE.test(req.query.user)
					? req.query.user
					: null;
			const rows = (
				await db.query(
					`SELECT p.id,p.plan->'fields'->>'0.1' AS title,p.updated_at,u.id AS user_id,u.email
					 FROM roast_plans p JOIN users u ON u.id=p.user_id
					 WHERE $1::uuid IS NULL OR p.user_id=$1
					 ORDER BY p.updated_at DESC`,
					[userFilter],
				)
			).rows;
			res.json({ ok: true, plans: rows });
		} catch (e) {
			next(e);
		}
	});
	app.get("/api/admin/audit", requireAuth, admin, async (req, res, next) => {
		try {
			const rows = (
				await db.query(
					`SELECT a.id,a.action,a.target,a.created_at,u.email AS actor_email
					 FROM audit_events a LEFT JOIN users u ON u.id=a.actor_user_id
					 ORDER BY a.created_at DESC LIMIT 50`,
				)
			).rows;
			res.json({ ok: true, events: rows });
		} catch (e) {
			next(e);
		}
	});
	app.get(
		"/api/admin/password-resets",
		requireAuth,
		admin,
		async (_req, res) => {
			const now = Date.now();
			const links = [];
			for (const [tokenHash, entry] of pendingResets) {
				if (entry.expiresAt <= now) {
					pendingResets.delete(tokenHash);
					continue;
				}
				links.push({
					email: entry.email,
					url: entry.url,
					expiresAt: new Date(entry.expiresAt).toISOString(),
				});
			}
			res.json({ ok: true, links });
		},
	);
	// ─── Backup (full-database export/import) ──────────────────────────────
	// Per-table column allowlists, in FK-dependency order (parents first). Insert follows this
	// order, delete runs it reversed. sessions and password_reset_tokens are deliberately not
	// part of a backup: they are short-lived secrets, and restoring them would resurrect revoked
	// access. Deleting users cascades both away on import anyway.
	const BACKUP_TABLES = [
		["users", ["id", "email", "password_hash", "role", "created_at", "disabled_at", "avatar"]],
		["app_settings", ["key", "value"]],
		["roast_plans", ["id", "user_id", "plan", "created_at", "updated_at"]],
		[
			"green_bean_lots",
			[
				"id", "user_id", "origin", "variety", "process", "producer", "purchase_date",
				"initial_weight_g", "remaining_weight_g", "cost_total", "moisture_pct",
				"density_g_l", "notes", "archived", "created_at", "updated_at",
			],
		],
		["bean_consumption", ["id", "lot_id", "user_id", "roast_plan_id", "weight_g", "created_at"]],
		["cupping_sessions", ["id", "user_id", "roast_plan_id", "data", "total_score", "created_at", "updated_at"]],
		["roasters", ["id", "user_id", "name", "model", "notes", "is_default", "overrides", "created_at", "updated_at"]],
		[
			"actual_roasts",
			[
				"id", "user_id", "roast_plan_id", "roaster_id", "filename", "original_content", "parsed",
				"after", "evaluation", "evaluation_status", "evaluation_error", "created_at", "updated_at",
			],
		],
		[
			"roasted_beans",
			[
				"id", "user_id", "name", "roaster", "origin", "process", "variety", "roast_level",
				"roast_date", "initial_weight_g", "url", "tasting_notes", "notes", "roast_plan_id",
				"archived", "created_at", "updated_at",
			],
		],
		[
			"brews",
			[
				"id", "user_id", "bean_id", "method", "dose_g", "water_g", "yield_g", "grinder",
				"grind_setting", "water_temp_c", "brew_time_s", "bloom_time_s", "rating",
				"recipe", "tasting_notes", "notes", "brewed_at", "created_at", "updated_at",
			],
		],
		["api_tokens", ["token_hash", "user_id", "name", "created_at", "last_used_at"]],
		["user_gear", ["user_id", "brewers", "grinders", "updated_at"]],
		["audit_events", ["id", "actor_user_id", "action", "target", "created_at"]],
	];
	const BACKUP_VERSION = 1;
	app.get("/api/admin/backup", requireAuth, admin, async (req, res, next) => {
		try {
			const tables = {};
			for (const [table, columns] of BACKUP_TABLES)
				tables[table] = (
					await db.query(`SELECT ${columns.join(",")} FROM ${table}`)
				).rows;
			await audit(req.user.id, "backup_exported");
			res.set(
				"Content-Disposition",
				`attachment; filename="roast-planner-backup-${new Date().toISOString().slice(0, 10)}.json"`,
			);
			res.json({
				ok: true,
				format: "roast-planner-backup",
				version: BACKUP_VERSION,
				exportedAt: new Date().toISOString(),
				tables,
			});
		} catch (e) {
			next(e);
		}
	});
	app.post(
		"/api/admin/backup/import",
		requireAuth,
		csrf,
		admin,
		async (req, res, next) => {
			try {
				const body = req.body;
				if (
					body.format !== "roast-planner-backup" ||
					body.version !== BACKUP_VERSION ||
					!body.tables ||
					typeof body.tables !== "object"
				)
					return res.status(400).json({ ok: false, code: "bad_backup" });
				// The restored database must still contain at least one active admin, or the
				// import would permanently lock everyone (including this caller) out.
				const users = Array.isArray(body.tables.users) ? body.tables.users : [];
				if (!users.some((u) => u.role === "admin" && !u.disabled_at))
					return res.status(400).json({ ok: false, code: "backup_has_no_admin" });
				const counts = {};
				const currentSessionHash = req.user.token_auth
					? null
					: hash(cookie(req, "rp_session"));
				const currentCsrfHash = req.user.csrf_hash ?? null;
				const currentUserId = req.user.id;
				await withTransaction(async (client) => {
					for (const [table] of [...BACKUP_TABLES].reverse())
						await client.query(`DELETE FROM ${table}`);
					for (const [table, columns] of BACKUP_TABLES) {
						const rows = Array.isArray(body.tables[table]) ? body.tables[table] : [];
						for (const row of rows) {
							const params = columns.map((column) => {
								const value = row[column];
								if (value === undefined) return null;
								// jsonb columns arrive as objects; everything else is scalar.
								return value !== null && typeof value === "object"
									? JSON.stringify(value)
									: value;
							});
							await client.query(
								`INSERT INTO ${table}(${columns.join(",")}) VALUES(${columns.map((_, i) => `$${i + 1}`).join(",")})`,
								params,
							);
						}
						counts[table] = rows.length;
					}
					// Deleting users cascaded this caller's session away. If the same user id
					// exists in the restored data, re-create the session so the admin who ran the
					// import stays signed in; otherwise they must log in with restored credentials.
					if (
						currentSessionHash &&
						currentCsrfHash &&
						users.some((u) => u.id === currentUserId)
					)
						await client.query(
							"INSERT INTO sessions(token_hash,user_id,csrf_hash,expires_at,last_seen_at) VALUES($1,$2,$3,now()+interval '14 days',now())",
							[currentSessionHash, currentUserId, currentCsrfHash],
						);
				});
				await audit(currentUserId, "backup_imported", JSON.stringify(counts).slice(0, 500));
				res.json({
					ok: true,
					counts,
					sessionKept: users.some((u) => u.id === currentUserId),
				});
			} catch (e) {
				next(e);
			}
		},
	);
	// Per-user export: the same shape, restricted to the caller's own rows (no password hashes,
	// no tokens) — a personal data takeout rather than a restorable server backup.
	app.get("/api/account/export", requireAuth, async (req, res, next) => {
		try {
			const mine = async (table, columns, userColumn = "user_id") =>
				(
					await db.query(
						`SELECT ${columns.join(",")} FROM ${table} WHERE ${userColumn}=$1`,
						[req.user.id],
					)
				).rows;
			const tables = {};
			for (const [table, columns] of BACKUP_TABLES) {
				if (table === "users" || table === "app_settings" || table === "api_tokens" || table === "audit_events")
					continue;
				tables[table] = await mine(table, columns);
			}
			res.set(
				"Content-Disposition",
				`attachment; filename="roast-planner-my-data-${new Date().toISOString().slice(0, 10)}.json"`,
			);
			res.json({
				ok: true,
				format: "roast-planner-user-export",
				version: BACKUP_VERSION,
				exportedAt: new Date().toISOString(),
				user: { email: req.user.email },
				tables,
			});
		} catch (e) {
			next(e);
		}
	});

	// The LLM model picker: which configured model runs prefill and roast reviews. Listing can
	// legitimately fail (no models configured yet) — the GET still succeeds so the admin page can
	// say so instead of erroring, and only a non-empty PUT requires the list for validation.
	app.get("/api/admin/llm", requireAuth, admin, async (req, res, next) => {
		try {
			const current = await llmModelSetting();
			let models = [];
			let modelsError = null;
			try {
				models = await listModels();
			} catch (e) {
				modelsError = "llm_unavailable";
			}
			res.json({ ok: true, current, models, modelsError });
		} catch (e) {
			next(e);
		}
	});
	app.put(
		"/api/admin/llm",
		requireAuth,
		csrf,
		admin,
		async (req, res, next) => {
			try {
				const model = req.body.model;
				if (typeof model !== "string" || model.length > 200)
					return res.status(400).json({ ok: false, code: "bad_request" });
				if (model !== "") {
					let models;
					try {
						models = await listModels();
					} catch (e) {
						return res
							.status(503)
							.json({ ok: false, code: "llm_unavailable" });
					}
					if (!models.some((m) => m.key === model))
						return res
							.status(400)
							.json({ ok: false, code: "unknown_model" });
				}
				await db.query(
					"UPDATE app_settings SET value=$1 WHERE key='llm_model'",
					[model],
				);
				await audit(req.user.id, "llm_model_changed", model || "auto");
				res.json({ ok: true, current: model });
			} catch (e) {
				next(e);
			}
		},
	);
	app.put(
		"/api/admin/signup-enabled",
		requireAuth,
		csrf,
		admin,
		async (req, res, next) => {
			try {
				if (typeof req.body.enabled !== "boolean")
					return res.status(400).json({ ok: false, code: "bad_request" });
				await db.query(
					"UPDATE app_settings SET value=$1 WHERE key='signup_enabled'",
					[String(req.body.enabled)],
				);
				await audit(req.user.id, "signup_toggled", String(req.body.enabled));
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);
	app.put(
		"/api/admin/users/:id/role",
		requireAuth,
		csrf,
		admin,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				if (!["user", "admin"].includes(req.body.role))
					return res.status(400).json({ ok: false, code: "bad_request" });
				if (isSelf(req) || (await isBootstrapAdmin(req.params.id)))
					return res.status(403).json({ ok: false, code: "forbidden" });
				const r = await db.query(
					"UPDATE users SET role=$1 WHERE id=$2 RETURNING id,email,role",
					[req.body.role, req.params.id],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				await audit(req.user.id, "role_changed", `${r.rows[0].email}->${req.body.role}`);
				res.json({ ok: true, user: r.rows[0] });
			} catch (e) {
				next(e);
			}
		},
	);
	app.put(
		"/api/admin/users/:id/disabled",
		requireAuth,
		csrf,
		admin,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				if (typeof req.body.disabled !== "boolean")
					return res.status(400).json({ ok: false, code: "bad_request" });
				if (isSelf(req) || (await isBootstrapAdmin(req.params.id)))
					return res.status(403).json({ ok: false, code: "forbidden" });
				const r = await db.query(
					"UPDATE users SET disabled_at=$1 WHERE id=$2 RETURNING id,email,disabled_at",
					[req.body.disabled ? new Date() : null, req.params.id],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				if (req.body.disabled)
					await db.query("DELETE FROM sessions WHERE user_id=$1", [
						req.params.id,
					]);
				await audit(
					req.user.id,
					req.body.disabled ? "user_disabled" : "user_enabled",
					r.rows[0].email,
				);
				res.json({ ok: true, user: r.rows[0] });
			} catch (e) {
				next(e);
			}
		},
	);
	app.delete(
		"/api/admin/users/:id",
		requireAuth,
		csrf,
		admin,
		requireUuidParam("id"),
		async (req, res, next) => {
			try {
				if (isSelf(req) || (await isBootstrapAdmin(req.params.id)))
					return res.status(403).json({ ok: false, code: "forbidden" });
				const r = await db.query(
					"DELETE FROM users WHERE id=$1 RETURNING email",
					[req.params.id],
				);
				if (!r.rowCount)
					return res.status(404).json({ ok: false, code: "not_found" });
				await audit(req.user.id, "user_deleted", r.rows[0].email);
				res.json({ ok: true });
			} catch (e) {
				next(e);
			}
		},
	);

	// Existing integrations remain authenticated but CSRF-protected for writes.
	app.post("/api/prefill", requireAuth, csrf, async (req, res) => {
		const url = typeof req.body?.url === "string" ? req.body.url.trim() : "";
		if (!url)
			return res
				.status(400)
				.json({ ok: false, code: "bad_url", error: "Missing url." });
		try {
			const result = await runPrefill(
				await fetchPageText(url),
				await llmModelSetting(),
			);
			res.json({ ok: true, ...result });
		} catch (err) {
			const code = err.code ?? "prefill_failed";
			res
				.status(
					code === "fetch_timeout"
						? 504
						: code === "bad_url"
							? 400
							: code === "no_model"
								? 503
								: 422,
				)
				.json({ ok: false, code, error: err.message });
		}
	});
	app.post("/api/alog", requireAuth, csrf, (req, res) => {
		try {
			if (typeof req.body?.content !== "string" || !req.body.content.trim())
				return res.status(400).json({ ok: false, code: "bad_request" });
			res.json({
				ok: true,
				...parseAlog(req.body.content, req.body.filename ?? "upload.alog"),
			});
		} catch (err) {
			res
				.status(422)
				.json({ ok: false, code: "unparseable_alog", error: err.message });
		}
	});
	// ─── Equipment (owned brewers + grinders) ──────────────────────────────
	app.get("/api/gear", requireAuth, async (req, res, next) => {
		try {
			const row = (
				await db.query("SELECT brewers,grinders FROM user_gear WHERE user_id=$1", [
					req.user.id,
				])
			).rows[0];
			res.json({
				ok: true,
				gear: { brewers: row?.brewers ?? [], grinders: row?.grinders ?? [] },
			});
		} catch (e) {
			next(e);
		}
	});
	app.put("/api/gear", requireAuth, csrf, async (req, res, next) => {
		try {
			const rawBrewers = req.body.brewers;
			const rawGrinders = req.body.grinders;
			if (!Array.isArray(rawBrewers) || !Array.isArray(rawGrinders))
				return res.status(400).json({ ok: false, code: "bad_gear" });
			const brewers = [...new Set(rawBrewers)];
			if (
				brewers.length > BREW_METHODS.length ||
				!brewers.every((k) => BREW_METHOD_KEYS.has(k))
			)
				return res
					.status(400)
					.json({ ok: false, code: "bad_gear", error: "unknown brewer key" });
			const grinders = [
				...new Set(
					rawGrinders
						.filter((g) => typeof g === "string")
						.map((g) => g.trim().slice(0, 100))
						.filter(Boolean),
				),
			].slice(0, 20);
			const row = (
				await db.query(
					`INSERT INTO user_gear(user_id,brewers,grinders,updated_at) VALUES($1,$2,$3,now())
					 ON CONFLICT (user_id) DO UPDATE SET brewers=$2, grinders=$3, updated_at=now()
					 RETURNING brewers,grinders`,
					[req.user.id, JSON.stringify(brewers), JSON.stringify(grinders)],
				)
			).rows[0];
			res.json({ ok: true, gear: { brewers: row.brewers, grinders: row.grinders } });
		} catch (e) {
			next(e);
		}
	});

	app.get("/api/brew-methods", requireAuth, (_req, res) =>
		res.json({
			ok: true,
			categories: BREW_CATEGORIES,
			methods: BREW_METHODS,
		}),
	);
	app.get("/api/openapi.json", requireAuth, (req, res) =>
		res.json(buildOpenApiSpec({ origin })),
	);
	app.get("/api/alog/library", requireAuth, async (_q, res) =>
		res.json({ ok: true, files: await listAlogLibrary() }),
	);
	app.get("/api/alog/library/:filename", requireAuth, async (req, res) => {
		try {
			res.json({
				ok: true,
				...(await readAlogFromLibrary(req.params.filename)),
			});
		} catch (e) {
			res
				.status(e.code === "not_found" ? 404 : 400)
				.json({ ok: false, code: e.code, error: e.message });
		}
	});

	app.use((req, res, next) => {
		// express.static resolves percent-encoding and repeated slashes before hitting disk, so
		// the blocklist must compare against the same normalized form or a request like
		// /%69ndex.html or //index.html would slip past this check and still be served below.
		let normalized;
		try {
			normalized = path.posix.normalize(decodeURIComponent(req.path));
		} catch {
			normalized = req.path;
		}
		return PUBLIC_SHELL_FILES.has(normalized)
			? res.status(302).location("/").end()
			: next();
	});
	// index:false — otherwise express.static serves index.html for any request path that
	// normalizes to a directory (e.g. "/", "/./", "//"), bypassing the shell-file blocklist
	// above the same way a direct filename request would.
	app.use(express.static(path.join(root, "public"), { index: false }));
	app.use("/shared", express.static(path.join(root, "shared")));
	// Self-hosted Swagger UI assets for /api-docs (CSP forbids CDN scripts).
	app.use(
		"/swagger",
		express.static(createRequire(import.meta.url).resolve("swagger-ui-dist/swagger-ui.css").replace(/swagger-ui\.css$/, "")),
	);
	app.use((err, _req, res, _next) => {
		// Malformed JSON / an oversized body are client errors (from express.json()'s parser),
		// not server faults — respect the status it already picked instead of masking every
		// bad request as a 500 with a logged stack trace.
		const status = err.status || err.statusCode;
		if (status >= 400 && status < 500)
			return res.status(status).json({ ok: false, code: "bad_request" });
		console.error(err);
		res.status(500).json({ ok: false, code: "internal_error" });
	});
	return app;
}
