// Browser-safe ESM (served at /shared/, imported by both server and client — no Node-only
// APIs) — the single implementation of cupping scoring/validation. The server always
// recomputes the total from coerceSession's output; a client-submitted total is never trusted.
//
// FLAVOR_TAXONOMY is an ORIGINAL taxonomy for this project: the family/subgroup/descriptor
// list and id strings are our own, deliberately not a transcription of the copyrighted
// SCA/WCR Coffee Taster's Flavor Wheel graphic or its exact category boundaries/wording.

export const SCORE_ATTRS = [
	"fragrance_aroma",
	"flavor",
	"aftertaste",
	"acidity",
	"body",
	"balance",
	"overall",
];

export const SCORE_LABELS = {
	fragrance_aroma: "Fragrance/Aroma",
	flavor: "Flavor",
	aftertaste: "Aftertaste",
	acidity: "Acidity",
	body: "Body",
	balance: "Balance",
	overall: "Overall",
};

export const TICK_ATTRS = ["uniformity", "clean_cup", "sweetness"];

export const TICK_LABELS = {
	uniformity: "Uniformity",
	clean_cup: "Clean cup",
	sweetness: "Sweetness",
};

export const STAGE_IDS = [
	"dry_fragrance",
	"pour",
	"crust_aroma",
	"break",
	"skim",
	"taste_1",
	"taste_2",
	"taste_3",
];

const STAGE_ORDER = Object.fromEntries(STAGE_IDS.map((s, i) => [s, i]));

export const DEFAULT_CUP_COUNT = 5;
export const MAX_CUP_COUNT = 12;
export const MAX_FLAVOR_TAGS = 32;
export const MAX_NOTES_CHARS = 4000;
const MAX_STAGE_ELAPSED_SEC = 24 * 60 * 60;

// 1-3 dot-separated segments, lowercase alnum(+underscore) in EVERY segment — three family
// ids (nutty_cocoa, green_vegetal, fermented_sour) have an underscore in the first segment.
const FLAVOR_ID_RE = /^[a-z0-9_]+(\.[a-z0-9_]+){0,2}$/;

export const FLAVOR_TAXONOMY = {
	fruity: {
		label: "Fruity",
		subgroups: {
			berry: ["blueberry", "blackberry", "strawberry"],
			citrus: ["orange", "lemon", "grapefruit"],
			stone_fruit: ["peach", "apricot", "cherry"],
			dried_fruit: ["raisin", "fig", "prune"],
		},
	},
	floral: {
		label: "Floral",
		subgroups: {
			blossom: ["jasmine", "orange_blossom", "chamomile"],
			herbal_floral: ["lavender", "rose"],
		},
	},
	sweet: {
		label: "Sweet",
		subgroups: {
			sugars: ["brown_sugar", "honey", "caramel"],
			vanilla: ["vanilla", "malt"],
		},
	},
	nutty_cocoa: {
		label: "Nutty / Cocoa",
		subgroups: {
			nutty: ["almond", "hazelnut", "peanut"],
			cocoa: ["dark_chocolate", "cocoa_powder"],
		},
	},
	spice: {
		label: "Spice",
		subgroups: {
			warm_spice: ["cinnamon", "clove", "nutmeg"],
			pungent: ["pepper", "anise"],
		},
	},
	roasted: {
		label: "Roasted",
		subgroups: {
			grain: ["toast", "cereal"],
			char: ["smoky", "tobacco", "pipe_tobacco"],
		},
	},
	green_vegetal: {
		label: "Green / Vegetal",
		subgroups: {
			fresh: ["cut_grass", "leafy", "herbaceous"],
			raw: ["beany", "peapod"],
		},
	},
	fermented_sour: {
		label: "Fermented / Sour",
		subgroups: {
			sour: ["citric", "acetic", "tart"],
			fermented: ["winey", "boozy", "overripe"],
		},
	},
	other: {
		label: "Other",
		subgroups: {
			chemical: ["rubber", "medicinal"],
			papery: ["papery", "musty", "woody"],
		},
	},
};

const snapQuarter = (v) => Math.round(v * 4) / 4;

export function blankSession(cupCount = DEFAULT_CUP_COUNT) {
	return {
		cup_count: cupCount,
		scores: Object.fromEntries(SCORE_ATTRS.map((a) => [a, 0])),
		ticks: Object.fromEntries(TICK_ATTRS.map((a) => [a, 0])),
		taint_cups: 0,
		fault_cups: 0,
		flavor_tags: [],
		stage_marks: [],
		ritual_started_at_iso: "",
		notes: "",
	};
}

/** total = sum(scored attrs) + 10*ticks/cupCount per tick attr - 2*taint - 4*fault, floored
 * at 0, rounded to 2dp. Pure arithmetic over whatever it's handed — call coerceSession first. */
export function computeTotalScore(scores, ticks, taintCups, faultCups, cupCount) {
	let total = SCORE_ATTRS.reduce((sum, a) => sum + Number(scores?.[a] ?? 0), 0);
	const cc = Number(cupCount);
	if (cc > 0)
		total += TICK_ATTRS.reduce((sum, a) => sum + (10 * Number(ticks?.[a] ?? 0)) / cc, 0);
	total -= 2 * Number(taintCups ?? 0);
	total -= 4 * Number(faultCups ?? 0);
	total = Math.max(0, total);
	return Math.round(total * 100) / 100;
}

/** Validates and clamps a session document. Throws Error with a human message on genuinely
 * bad input (malformed flavor id, unknown stage, too many tags); everything else is clamped. */
export function coerceSession(session) {
	const raw = session && typeof session === "object" ? session : {};

	// Tolerant of a missing cup_count (defaults it), same stance as every other field below —
	// but a *present and garbage* value (NaN, a string, etc.) is a genuine client bug, not an
	// absent field, so that still throws rather than silently defaulting.
	const cupCountRaw = raw.cup_count == null ? DEFAULT_CUP_COUNT : Number(raw.cup_count);
	if (!Number.isFinite(cupCountRaw)) throw new Error("cup_count must be a finite number");
	const cup_count = Math.max(1, Math.min(MAX_CUP_COUNT, Math.round(cupCountRaw)));

	const scores = {};
	for (const attr of SCORE_ATTRS) {
		const f = Number(raw.scores?.[attr] ?? 0);
		if (!Number.isFinite(f)) throw new Error(`${attr} must be a finite number`);
		scores[attr] = f <= 0 ? 0 : Math.round(snapQuarter(Math.max(6, Math.min(10, f))) * 100) / 100;
	}

	const ticks = {};
	for (const attr of TICK_ATTRS) {
		const f = Number(raw.ticks?.[attr] ?? 0);
		if (!Number.isFinite(f)) throw new Error(`${attr} must be a finite number`);
		ticks[attr] = Math.max(0, Math.min(cup_count, Math.round(f)));
	}

	const taintRaw = Number(raw.taint_cups ?? 0);
	if (!Number.isFinite(taintRaw)) throw new Error("taint_cups must be a finite number");
	const taint_cups = Math.max(0, Math.min(cup_count, Math.round(taintRaw)));

	const faultRaw = Number(raw.fault_cups ?? 0);
	if (!Number.isFinite(faultRaw)) throw new Error("fault_cups must be a finite number");
	const fault_cups = Math.max(0, Math.min(cup_count, Math.round(faultRaw)));

	const notes = String(raw.notes ?? "").slice(0, MAX_NOTES_CHARS);

	const flavor_tags = [];
	const seen = new Set();
	for (const tag of Array.isArray(raw.flavor_tags) ? raw.flavor_tags : []) {
		if (typeof tag !== "string" || !FLAVOR_ID_RE.test(tag))
			throw new Error(`invalid flavor tag id: ${JSON.stringify(tag)}`);
		if (seen.has(tag)) continue;
		seen.add(tag);
		flavor_tags.push(tag);
	}
	if (flavor_tags.length > MAX_FLAVOR_TAGS)
		throw new Error(
			`at most ${MAX_FLAVOR_TAGS} flavor tags allowed, got ${flavor_tags.length} (after de-duplication)`,
		);

	const markByStage = {};
	for (const mark of Array.isArray(raw.stage_marks) ? raw.stage_marks : []) {
		const stage = mark?.stage;
		// Object.hasOwn, not `in` — `in` walks the prototype chain, so a stage value of
		// "toString"/"constructor"/etc. would pass validation and later corrupt the sort below
		// (STAGE_ORDER["toString"] is a function, not a number).
		if (!Object.hasOwn(STAGE_ORDER, stage))
			throw new Error(`unknown cupping stage: ${JSON.stringify(stage)}`);
		const elapsedRaw = Number(mark.elapsed_sec);
		if (!Number.isFinite(elapsedRaw)) throw new Error("elapsed_sec must be a finite number");
		const elapsed_sec = Math.max(0, Math.min(MAX_STAGE_ELAPSED_SEC, elapsedRaw));
		markByStage[stage] = {
			stage,
			elapsed_sec,
			marked_at_iso: String(mark.marked_at_iso ?? ""),
		};
	}
	const stage_marks = Object.values(markByStage).sort(
		(a, b) => STAGE_ORDER[a.stage] - STAGE_ORDER[b.stage],
	);

	return {
		cup_count,
		scores,
		ticks,
		taint_cups,
		fault_cups,
		notes,
		flavor_tags,
		stage_marks,
		ritual_started_at_iso: String(raw.ritual_started_at_iso ?? ""),
	};
}
