import test from "node:test";
import assert from "node:assert/strict";
import { computeMachineProfile } from "../shared/learn.js";

function plan({
	fcAnchor = "7:30",
	refine = "0",
	condition = "",
	batchCorrection = "",
	actualFc,
	actualBt = {},
} = {}) {
	return {
		fields: { "2.1": "single", "1.4": fcAnchor, "1.6": refine, "5.6": condition, "6.4": batchCorrection },
		planActual: {
			charge: { actualBt: actualBt.charge ?? "" },
			tp: { actualBt: actualBt.tp ?? "" },
			yellow: { actualBt: actualBt.yellow ?? "" },
			fc: { actualTime: actualFc ?? "", actualBt: actualBt.fc ?? "" },
			drop: { actualBt: actualBt.drop ?? "" },
		},
	};
}

test("learn: empty history stays at reference defaults", () => {
	const profile = computeMachineProfile([]);
	assert.equal(profile.pace.value, 1);
	assert.equal(profile.pace.n, 0);
	assert.equal(profile.pace.source, "reference");
	assert.equal(profile.bandsSource, "reference");
	assert.equal(profile.totalPlans, 0);
	for (const m of ["charge", "tp", "yellow", "fc", "drop"]) {
		assert.equal(profile.bands[m].source, "reference");
		assert.equal(profile.bands[m].medianC, null);
	}
});

test("learn: non-array input is treated as no history, not a throw", () => {
	assert.doesNotThrow(() => computeMachineProfile(null));
	assert.doesNotThrow(() => computeMachineProfile(undefined));
	assert.equal(computeMachineProfile(null).totalPlans, 0);
});

test("learn: a single completed roast is not enough to trust — pace stays 1.0", () => {
	// anchor 7:30 = 450s, actual FC 8:15 = 495s (ratio 1.1) — only one data point.
	const profile = computeMachineProfile([plan({ fcAnchor: "7:30", actualFc: "8:15" })]);
	assert.equal(profile.pace.value, 1);
	assert.equal(profile.pace.n, 1);
	assert.equal(profile.pace.source, "reference");
});

test("learn: two or more roasts running consistently slower learn a >1 pace factor", () => {
	// Every roast ran 10% slower than its own anchor predicted.
	const plans = [
		plan({ fcAnchor: "7:30", actualFc: "8:15" }), // 450 -> 495, ratio 1.1
		plan({ fcAnchor: "8:00", actualFc: "8:48" }), // 480 -> 528, ratio 1.1
		plan({ fcAnchor: "9:00", actualFc: "9:54" }), // 540 -> 594, ratio 1.1
	];
	const profile = computeMachineProfile(plans);
	assert.equal(profile.pace.n, 3);
	assert.equal(profile.pace.source, "learned");
	assert.ok(Math.abs(profile.pace.value - 1.1) < 0.001, `expected ~1.1, got ${profile.pace.value}`);
});

test("learn: refine and bean-condition corrections are folded into the predicted anchor before pacing", () => {
	// anchor 7:30 (450s) + refine +0:15 (15s) + condition -0:05 (-5s) = predicted 460s.
	// Actual exactly matches the corrected prediction, so pace should read as 1.0 even though
	// naively comparing actual against the raw uncorrected anchor would suggest otherwise.
	const plans = [
		plan({ fcAnchor: "7:30", refine: "+0:15", condition: "-0:05", actualFc: "7:40" }), // 460s
		plan({ fcAnchor: "8:00", refine: "+0:15", condition: "-0:05", actualFc: "8:10" }), // 490s
	];
	const profile = computeMachineProfile(plans);
	assert.equal(profile.pace.source, "learned");
	assert.ok(Math.abs(profile.pace.value - 1) < 0.001, `expected ~1.0, got ${profile.pace.value}`);
});

test("learn: a plan with no anchor or no actual FC time is silently skipped, not counted or thrown on", () => {
	const plans = [
		plan({ fcAnchor: "", actualFc: "8:15" }), // no anchor -> unpredictable, skipped
		plan({ fcAnchor: "7:30", actualFc: "" }), // never roasted -> skipped
		{ fields: {}, planActual: null }, // malformed -> skipped
		{}, // completely empty -> skipped
	];
	assert.doesNotThrow(() => computeMachineProfile(plans));
	const profile = computeMachineProfile(plans);
	assert.equal(profile.pace.n, 0);
	assert.equal(profile.pace.source, "reference");
	assert.equal(profile.totalPlans, 4);
});

test("learn: temperature bands require 3+ readings per milestone, independently per milestone", () => {
	const plans = [
		plan({ actualBt: { charge: "160", fc: "180" } }),
		plan({ actualBt: { charge: "165", fc: "185" } }),
		plan({ actualBt: { charge: "170", fc: "190" } }), // charge and fc now have 3 each
		plan({ actualBt: { tp: "85" } }), // tp only has 1 reading
	];
	const profile = computeMachineProfile(plans);
	assert.equal(profile.bands.charge.source, "learned");
	assert.equal(profile.bands.charge.n, 3);
	assert.equal(profile.bands.charge.medianC, 165);
	assert.deepEqual(profile.bands.charge.rangeC, [160, 170]);
	assert.equal(profile.bands.fc.source, "learned");
	assert.equal(profile.bands.fc.medianC, 185);
	// Below the 3-sample threshold — stays reference, not a false "learned" reading from 1 point.
	assert.equal(profile.bands.tp.source, "reference");
	assert.equal(profile.bands.tp.medianC, null);
	assert.equal(profile.bands.tp.n, 1);
	assert.equal(profile.bandsSource, "learned"); // true because AT LEAST ONE milestone learned
});

test("learn: non-numeric actualBt values are ignored rather than poisoning the median", () => {
	const plans = [
		plan({ actualBt: { charge: "160" } }),
		plan({ actualBt: { charge: "not a number" } }),
		plan({ actualBt: { charge: "" } }),
		plan({ actualBt: { charge: "170" } }),
		plan({ actualBt: { charge: "180" } }),
	];
	const profile = computeMachineProfile(plans);
	assert.equal(profile.bands.charge.n, 3); // only the 3 valid numeric readings counted
	assert.equal(profile.bands.charge.medianC, 170);
});

test("learn: field 6.4 is included in the fitted prediction, so it isn't double-counted once pace is learned", () => {
	// anchor 8:00 (480s) + 6.4 +1:00 (60s) = 540s predicted; actual FC lands exactly there, every
	// time, so a correct fit reads pace as 1.0 — NOT ~1.125, which is what a fit that excluded 6.4
	// (comparing 540s actual against a 480s prediction) would wrongly report.
	const plans = [
		plan({ fcAnchor: "8:00", batchCorrection: "+1:00", actualFc: "9:00" }),
		plan({ fcAnchor: "8:30", batchCorrection: "+1:00", actualFc: "9:30" }),
		plan({ fcAnchor: "9:00", batchCorrection: "+1:00", actualFc: "10:00" }),
	];
	const profile = computeMachineProfile(plans);
	assert.equal(profile.pace.source, "learned");
	assert.ok(Math.abs(profile.pace.value - 1) < 0.001, `expected ~1.0, got ${profile.pace.value}`);
});

test("learn: a single wildly-off actual-FC entry is excluded as an outlier, not allowed to define the pace", () => {
	// Second roast's actual FC (0:05) against an 8:00 anchor is a near-certain typo, not a real
	// 96% speed-up — the ratio (0.0104) sits far outside the plausible band and must be dropped
	// before the median, not clamped into it as if it were real signal from a small sample.
	const plans = [
		plan({ fcAnchor: "8:00", actualFc: "8:10" }), // ratio ~1.02, plausible
		plan({ fcAnchor: "8:00", actualFc: "0:05" }), // ratio ~0.01, implausible — excluded
	];
	const profile = computeMachineProfile(plans);
	// Only one plausible sample remains, below MIN_PACE_SAMPLES — falls back to reference rather
	// than "learning" a pace from what's actually just the one plausible reading.
	assert.equal(profile.pace.n, 1);
	assert.equal(profile.pace.source, "reference");
	assert.equal(profile.pace.value, 1);
});

test("learn: the learned pace is clamped to a defensible band even with several agreeing samples", () => {
	// Three roasts all reading a ~0.55 ratio — each one individually within the per-sample
	// plausible band (so none get excluded as an outlier), but a median that far below 1 is still
	// implausible for a real machine. The hard clamp exists for exactly this shape of history,
	// where per-sample filtering alone wouldn't catch it.
	const plans = [
		plan({ fcAnchor: "8:00", actualFc: "4:20" }), // 260/480 = 0.542
		plan({ fcAnchor: "8:00", actualFc: "4:24" }), // 264/480 = 0.55
		plan({ fcAnchor: "8:00", actualFc: "4:28" }), // 268/480 = 0.558
	];
	const profile = computeMachineProfile(plans);
	assert.equal(profile.pace.n, 3); // all three were plausible per-sample, none excluded
	assert.equal(profile.pace.source, "learned");
	assert.equal(profile.pace.value, 0.6, "median ~0.55 should be clamped up to the 0.6 floor");
});
