import test from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import { setup, signup, password } from "./helpers.js";

async function bootstrapAdmin(agent) {
	const response = await agent.post("/api/auth/bootstrap").send({
		email: "snowspeeder@gmail.com",
		password,
		setupToken: "a-secure-bootstrap-token",
	});
	assert.equal(response.status, 201);
	return response.body.csrfToken;
}

test("beans: CRUD, computed remaining weight, ownership", async () => {
	const { app, agent } = await setup();
	const { csrf } = await signup(agent, "beans@example.com");

	const created = await agent.post("/api/beans").set("x-csrf-token", csrf).send({
		name: "Kenya AA — own roast",
		roaster: "Home",
		origin: "Kenya",
		process: "washed",
		roastLevel: "light",
		roastDate: "2026-08-01",
		initialWeightG: 210,
		tastingNotes: "blackcurrant, tomato",
	});
	assert.equal(created.status, 201);
	const bean = created.body.bean;
	assert.equal(bean.remainingWeightG, 210);

	// Missing name rejected
	assert.equal(
		(await agent.post("/api/beans").set("x-csrf-token", csrf).send({ roaster: "X" })).status,
		400,
	);

	// Logging brews reduces computed remaining
	for (const dose of [18, 15]) {
		const brew = await agent.post("/api/brews").set("x-csrf-token", csrf).send({
			beanId: bean.id,
			method: "v60",
			doseG: dose,
			waterG: dose * 16,
		});
		assert.equal(brew.status, 201);
	}
	const list = await agent.get("/api/beans");
	assert.equal(list.body.beans[0].remainingWeightG, 210 - 33);
	assert.equal(list.body.beans[0].brewCount, 2);

	// Update
	const updated = await agent
		.put(`/api/beans/${bean.id}`)
		.set("x-csrf-token", csrf)
		.send({ name: "Kenya AA (rested)", archived: true });
	assert.equal(updated.status, 200);
	assert.equal(updated.body.bean.name, "Kenya AA (rested)");
	assert.equal(updated.body.bean.archived, true);
	assert.equal(updated.body.bean.origin, "Kenya"); // untouched fields survive

	// Ownership
	const stranger = request.agent(app);
	const { csrf: strangerCsrf } = await signup(stranger, "other@example.com");
	assert.equal((await stranger.get("/api/beans")).body.beans.length, 0);
	assert.equal(
		(
			await stranger
				.put(`/api/beans/${bean.id}`)
				.set("x-csrf-token", strangerCsrf)
				.send({ name: "hijack" })
		).status,
		404,
	);
	assert.equal(
		(
			await stranger
				.post("/api/brews")
				.set("x-csrf-token", strangerCsrf)
				.send({ beanId: bean.id, method: "v60" })
		).status,
		404,
	);

	// Delete bean: brews keep existing with bean_id nulled
	assert.equal(
		(await agent.delete(`/api/beans/${bean.id}`).set("x-csrf-token", csrf)).status,
		200,
	);
	const brews = await agent.get("/api/brews");
	assert.equal(brews.body.brews.length, 2);
	assert.equal(brews.body.brews[0].beanId, null);
});

test("brews: validation, update, filter by bean, method taxonomy", async () => {
	const { agent } = await setup();
	const { csrf } = await signup(agent, "brews@example.com");

	const methods = await agent.get("/api/brew-methods");
	assert.equal(methods.status, 200);
	assert.equal(methods.body.categories.length, 3);
	assert.equal(methods.body.methods.some((m) => m.key === "moka"), true);
	const harioSwitch = methods.body.methods.find((m) => m.key === "hario-switch");
	assert.equal(harioSwitch?.category, "immersion");

	// Every method must have a silhouette so the picker never renders an empty tile
	const { BREW_METHODS, BREW_SILHOUETTES } = await import("../shared/brew-data.js");
	for (const method of BREW_METHODS)
		assert.equal(
			BREW_SILHOUETTES[method.key]?.length > 0,
			true,
			`missing silhouette for ${method.key}`,
		);

	// Unknown method rejected
	assert.equal(
		(await agent.post("/api/brews").set("x-csrf-token", csrf).send({ method: "teapot" })).status,
		400,
	);
	// Rating out of range rejected
	assert.equal(
		(
			await agent
				.post("/api/brews")
				.set("x-csrf-token", csrf)
				.send({ method: "v60", rating: 11 })
		).status,
		400,
	);
	// Non-integer time rejected
	assert.equal(
		(
			await agent
				.post("/api/brews")
				.set("x-csrf-token", csrf)
				.send({ method: "v60", brewTimeS: 2.5 })
		).status,
		400,
	);

	const created = await agent.post("/api/brews").set("x-csrf-token", csrf).send({
		method: "aeropress",
		doseG: 15,
		waterG: 230,
		waterTempC: 92,
		brewTimeS: 150,
		grinder: "Comandante",
		grindSetting: "22 clicks",
		rating: 8,
		recipe: "inverted · fill to 230 g · stir 3× · steep 2:00 · press 30 s",
		tastingNotes: "sweet, cocoa, round body",
	});
	assert.equal(created.status, 201);
	assert.equal(created.body.brew.rating, 8);
	assert.equal(created.body.brew.recipe, "inverted · fill to 230 g · stir 3× · steep 2:00 · press 30 s");

	const updated = await agent
		.put(`/api/brews/${created.body.brew.id}`)
		.set("x-csrf-token", csrf)
		.send({ rating: 6, notes: "slightly over-extracted" });
	assert.equal(updated.status, 200);
	assert.equal(updated.body.brew.rating, 6);
	assert.equal(updated.body.brew.method, "aeropress"); // untouched fields survive
	assert.equal(updated.body.brew.tastingNotes, "sweet, cocoa, round body");
	assert.equal(updated.body.brew.recipe.startsWith("inverted"), true);

	const bean = (
		await agent.post("/api/beans").set("x-csrf-token", csrf).send({ name: "B" })
	).body.bean;
	await agent
		.post("/api/brews")
		.set("x-csrf-token", csrf)
		.send({ method: "espresso", beanId: bean.id, doseG: 18, yieldG: 36 });
	const filtered = await agent.get(`/api/brews?bean=${bean.id}`);
	assert.equal(filtered.body.brews.length, 1);
	assert.equal(filtered.body.brews[0].method, "espresso");
	assert.equal(filtered.body.brews[0].beanName, "B");

	assert.equal(
		(
			await agent
				.delete(`/api/brews/${created.body.brew.id}`)
				.set("x-csrf-token", csrf)
		).status,
		200,
	);
	assert.equal((await agent.get("/api/brews")).body.brews.length, 1);
});

test("api tokens: bearer auth works, skips CSRF, revocation kills access", async () => {
	const { app, agent } = await setup();
	const { csrf } = await signup(agent, "tokens@example.com");

	const created = await agent
		.post("/api/tokens")
		.set("x-csrf-token", csrf)
		.send({ name: "cli" });
	assert.equal(created.status, 201);
	assert.match(created.body.token, /^rpt_/);

	// Bearer client: no cookies, no CSRF header — reads and writes both work
	const bearer = created.body.token;
	const anonymous = request(app);
	const me = await anonymous.get("/api/auth/me").set("authorization", `Bearer ${bearer}`);
	assert.equal(me.status, 200);
	assert.equal(me.body.user.email, "tokens@example.com");
	const write = await anonymous
		.post("/api/beans")
		.set("authorization", `Bearer ${bearer}`)
		.send({ name: "Token bean" });
	assert.equal(write.status, 201);

	// Wrong token fails; listing shows metadata only
	assert.equal(
		(await anonymous.get("/api/auth/me").set("authorization", "Bearer rpt_nope")).status,
		401,
	);
	const list = await agent.get("/api/tokens");
	assert.equal(list.body.tokens.length, 1);
	assert.equal(list.body.tokens[0].name, "cli");
	assert.equal(list.body.tokens[0].id, created.body.id);
	assert.equal(String(list.body.tokens[0]).includes("rpt_"), false);

	// Revoke → immediate 401
	assert.equal(
		(await agent.delete(`/api/tokens/${created.body.id}`).set("x-csrf-token", csrf)).status,
		200,
	);
	assert.equal(
		(await anonymous.get("/api/auth/me").set("authorization", `Bearer ${bearer}`)).status,
		401,
	);
});

test("backup: export → import round-trips data and keeps the admin session", async () => {
	const { app, agent } = await setup();
	const adminCsrf = await bootstrapAdmin(agent);

	// Seed data across features as a second user
	const user = request.agent(app);
	const { csrf: userCsrf } = await signup(user, "data@example.com");
	await user.post("/api/plans").set("x-csrf-token", userCsrf).send({ plan: { fields: { "0.1": "Backup plan" } } });
	const bean = (
		await user.post("/api/beans").set("x-csrf-token", userCsrf).send({ name: "Backup bean", initialWeightG: 200 })
	).body.bean;
	await user.post("/api/brews").set("x-csrf-token", userCsrf).send({ method: "chemex", beanId: bean.id, doseG: 30, waterG: 500, rating: 9 });
	await user.post("/api/inventory").set("x-csrf-token", userCsrf).send({ origin: "Colombia", initialWeightG: 1000 });

	const exported = await agent.get("/api/admin/backup");
	assert.equal(exported.status, 200);
	assert.match(exported.headers["content-disposition"], /attachment/);
	const backup = exported.body;
	assert.equal(backup.format, "roast-planner-backup");
	assert.equal(backup.tables.users.length, 2);
	assert.equal(backup.tables.roasted_beans.length, 1);
	assert.equal(backup.tables.brews.length, 1);
	assert.equal(backup.tables.green_bean_lots.length, 1);

	// Non-admin cannot export or import
	assert.equal((await user.get("/api/admin/backup")).status, 403);

	// Import replaces everything; the importing admin's session survives
	const imported = await agent
		.post("/api/admin/backup/import")
		.set("x-csrf-token", adminCsrf)
		.send(backup);
	assert.equal(imported.status, 200);
	assert.equal(imported.body.sessionKept, true);
	assert.equal(imported.body.counts.users, 2);
	assert.equal((await agent.get("/api/auth/me")).status, 200);

	// Data round-tripped: the user logs back in (their session was not preserved) and finds it
	const userAgain = request.agent(app);
	const login = await userAgain
		.post("/api/auth/login")
		.send({ email: "data@example.com", password });
	assert.equal(login.status, 200);
	assert.equal((await userAgain.get("/api/beans")).body.beans.length, 1);
	assert.equal((await userAgain.get("/api/beans")).body.beans[0].remainingWeightG, 170);
	assert.equal((await userAgain.get("/api/brews")).body.brews.length, 1);
	assert.equal((await userAgain.get("/api/inventory")).body.lots.length, 1);

	// A backup with no active admin is refused outright
	const noAdmin = structuredClone(backup);
	noAdmin.tables.users = noAdmin.tables.users.filter((u) => u.role !== "admin");
	assert.equal(
		(
			await agent
				.post("/api/admin/backup/import")
				.set("x-csrf-token", adminCsrf)
				.send(noAdmin)
		).status,
		400,
	);
	// Garbage is refused
	assert.equal(
		(
			await agent
				.post("/api/admin/backup/import")
				.set("x-csrf-token", adminCsrf)
				.send({ format: "nope" })
		).status,
		400,
	);
});

function makeAlog(title = "Roast") {
	const timex = [], temp1 = [], temp2 = [];
	for (let i = 0; i <= 20; i++) {
		timex.push(i * 30);
		temp1.push(200 + i);
		temp2.push(i < 3 ? 180 - i * 30 : 90 + (i - 3) * 7);
	}
	return JSON.stringify({ title, mode: "C", weight: [250, 212, "g"], timex, temp1, temp2, timeindex: [1, 8, 14, 0, 0, 0, 20, 0] });
}

test("roaster profile aggregates uploaded roasts; plan chat is grounded in it", async () => {
	const chatCalls = [];
	const { agent } = await setup(
		{},
		{
			evaluateRoast: async () => ({ summary: "ok", grade: "good", highlights: [], concerns: [], suggestions: [], planComparison: null }),
			runPlanChat: async (args) => {
				chatCalls.push(args);
				return { reply: "Drop 20 seconds earlier.", model: "test" };
			},
		},
	);
	const { csrf } = await signup(agent, "chat@example.com");

	// Empty profile before any uploads
	const empty = await agent.get("/api/roaster-profile");
	assert.equal(empty.status, 200);
	assert.equal(empty.body.profile.n, 0);

	// Upload two roasts → profile aggregates them
	for (const title of ["r1", "r2"]) {
		const up = await agent
			.post("/api/roasts")
			.set("x-csrf-token", csrf)
			.send({ filename: `${title}.alog`, content: makeAlog(title) });
		assert.equal(up.status, 201);
	}
	const profile = (await agent.get("/api/roaster-profile")).body.profile;
	assert.equal(profile.n, 2);
	assert.equal(Number.isFinite(profile.medians.turningPointS), true);
	assert.equal(Number.isFinite(profile.medians.firstCrackTempC), true);
	assert.equal(Number.isFinite(profile.rorCPerMin.maillard), true);

	// Chat receives the plan, the coerced messages, and both learned profiles
	const chat = await agent.post("/api/plan-chat").set("x-csrf-token", csrf).send({
		plan: { fields: { "0.1": "Chat plan", "1.4": "8:30" } },
		messages: [{ role: "user", content: "Why is drop so late?" }],
	});
	assert.equal(chat.status, 200);
	assert.equal(chat.body.reply, "Drop 20 seconds earlier.");
	assert.equal(chatCalls[0].plan.fields["0.1"], "Chat plan");
	assert.equal(chatCalls[0].roasterProfile.n, 2);
	assert.equal(Array.isArray(chatCalls[0].messages), true);

	// Bad chat bodies are rejected before any model call
	assert.equal(
		(await agent.post("/api/plan-chat").set("x-csrf-token", csrf).send({ plan: {}, messages: [] })).status,
		400,
	);
	assert.equal(
		(
			await agent
				.post("/api/plan-chat")
				.set("x-csrf-token", csrf)
				.send({ plan: {}, messages: [{ role: "assistant", content: "hi" }] })
		).status,
		400,
	);
	assert.equal(chatCalls.length, 1);
});

test("gear: owned brewers/grinders round-trip, validate, and stay private", async () => {
	const { app, agent } = await setup();
	const { csrf } = await signup(agent, "gear@example.com");

	// Defaults to empty
	const empty = await agent.get("/api/gear");
	assert.equal(empty.status, 200);
	assert.deepEqual(empty.body.gear, { brewers: [], grinders: [] });

	// Save + read back (upsert twice to cover the ON CONFLICT path)
	const first = await agent
		.put("/api/gear")
		.set("x-csrf-token", csrf)
		.send({ brewers: ["v60", "aeropress"], grinders: ["Comandante C40"] });
	assert.equal(first.status, 200);
	const second = await agent
		.put("/api/gear")
		.set("x-csrf-token", csrf)
		.send({ brewers: ["v60", "hario-switch", "v60"], grinders: ["Comandante C40", "DF64", "  "] });
	assert.equal(second.status, 200);
	assert.deepEqual(second.body.gear.brewers, ["v60", "hario-switch"]); // deduped
	assert.deepEqual(second.body.gear.grinders, ["Comandante C40", "DF64"]); // blank dropped
	assert.deepEqual((await agent.get("/api/gear")).body.gear.brewers, ["v60", "hario-switch"]);

	// Unknown brewer keys and non-array bodies are rejected
	assert.equal(
		(
			await agent
				.put("/api/gear")
				.set("x-csrf-token", csrf)
				.send({ brewers: ["teapot"], grinders: [] })
		).status,
		400,
	);
	assert.equal(
		(await agent.put("/api/gear").set("x-csrf-token", csrf).send({ brewers: "v60", grinders: [] })).status,
		400,
	);

	// Private per user
	const request = (await import("supertest")).default;
	const stranger = request.agent(app);
	await signup(stranger, "gear2@example.com");
	assert.deepEqual((await stranger.get("/api/gear")).body.gear.brewers, []);
});

test("academy shell page requires a session and lesson audio ids all exist", async () => {
	const { agent } = await setup();
	const anonymous = await agent.get("/academy");
	assert.equal(anonymous.status, 302);
	assert.equal(anonymous.headers.location, "/login");
	await signup(agent, "learner@example.com");
	const page = await agent.get("/academy");
	assert.equal(page.status, 200);
	assert.match(page.text, /Academy/);
	// Every slide must have committed narration audio — a missing file would silently break
	// the hands-free autoplay flow.
	const { ACADEMY_TRACKS } = await import("../public/js/academy-content.js");
	const { existsSync } = await import("node:fs");
	for (const track of ACADEMY_TRACKS)
		for (const lesson of track.lessons)
			for (const slide of lesson.slides)
				assert.equal(
					existsSync(new URL(`../public/academy-audio/${slide.id}.mp3`, import.meta.url).pathname),
					true,
					`missing audio for ${slide.id}`,
				);
});

test("per-user export and openapi spec", async () => {
	const { agent } = await setup();
	const { csrf } = await signup(agent, "export@example.com");
	await agent.post("/api/beans").set("x-csrf-token", csrf).send({ name: "Mine" });

	const exported = await agent.get("/api/account/export");
	assert.equal(exported.status, 200);
	assert.equal(exported.body.format, "roast-planner-user-export");
	assert.equal(exported.body.tables.roasted_beans.length, 1);
	assert.equal(exported.body.tables.users, undefined);
	assert.equal(exported.body.tables.api_tokens, undefined);

	const spec = await agent.get("/api/openapi.json");
	assert.equal(spec.status, 200);
	assert.equal(spec.body.openapi, "3.0.3");
	assert.equal(!!spec.body.paths["/api/brews"], true);
	assert.equal(!!spec.body.paths["/api/admin/backup/import"], true);
	assert.equal(!!spec.body.components.securitySchemes.bearerAuth, true);
});
