import { FIELD_IDS, blankPlan } from "/shared/fields.js?v=__ASSET_VERSION__";
import { computeLedger } from "/shared/ledger.js?v=__ASSET_VERSION__";
import {
	formatDuration,
	formatSigned,
	parseRangeMidpoint,
} from "/shared/time.js?v=__ASSET_VERSION__";
import {
	CULTIVARS,
	findCultivar,
	findProcess,
	findRoastLevel,
} from "/shared/reference-data.js?v=__ASSET_VERSION__";
import { buildPlanCurve, pointsToPathD, tToX, tempToY } from "/shared/curve.js?v=__ASSET_VERSION__";
import { initPrefillPanel } from "./prefill-ui.js?v=__ASSET_VERSION__";
import { initAlogPanel } from "./alog-ui.js?v=__ASSET_VERSION__";
import { initPrint } from "./print.js?v=__ASSET_VERSION__";
import { initLotPicker } from "./lot-picker.js?v=__ASSET_VERSION__";
import { api, protectedFetch, csrfToken } from "./api.js?v=__ASSET_VERSION__";
import { applyAvatar, initSideNav } from "./nav.js?v=__ASSET_VERSION__";
import { wireWhyPanels } from "./why-panels.js?v=__ASSET_VERSION__";
import { initFieldHelp } from "./field-help.js?v=__ASSET_VERSION__";
import { initPlanChat } from "./plan-chat-ui.js?v=__ASSET_VERSION__";
import { showToast } from "./toast.js?v=__ASSET_VERSION__";

const FIELD_ID_SET = new Set(FIELD_IDS);
const STORAGE_PREFIX = "roastPlannerPlan.v2";
let storageKey = null;
let remotePlanId = null;
let draftSyncedAt = null;
let plans = [];
let lastDrawerOpener = null;
let deferredInstallPrompt = null;
let lotPicker = null;
const BAND_DOMAIN = [60, 220]; // shared °C domain for every band-track in the Machine Plan section

// Learned per-user machine profile (pace factor + temperature bands) from shared/learn.js — see
// its header comment. Cached to localStorage per user so it's available offline, same pattern as
// the plan draft cache below.
const MACHINE_PROFILE_KEY_PREFIX = "roastPlannerMachineProfile.v1";
let machineProfile = null;
// Learned roaster behavior from uploaded .alogs (server-aggregated) — used as the plan
// curve's fallback milestone temps so the suggested curve matches this user's machine.
let roasterProfile = null;

async function loadRoasterProfile() {
	try {
		const response = await fetch("/api/roaster-profile");
		if (response.ok) roasterProfile = (await response.json()).profile;
		// A manual pace tweak on the default machine (Machines page) beats the plan-log
		// learned pace in the ledger.
		if (Number.isFinite(roasterProfile?.paceFactorOverride))
			machineProfile = {
				...(machineProfile ?? {}),
				pace: {
					value: roasterProfile.paceFactorOverride,
					source: "override",
					roasterName: roasterProfile.roaster?.name ?? null,
				},
			};
	} catch {
		/* offline — curve falls back to reference machine temps */
	}
}

async function loadMachineProfile(userId) {
	const cacheKey = `${MACHINE_PROFILE_KEY_PREFIX}:${userId}`;
	try {
		const response = await fetch("/api/machine-profile");
		if (response.ok) {
			machineProfile = (await response.json()).profile;
			localStorage.setItem(cacheKey, JSON.stringify(machineProfile));
			return;
		}
	} catch {
		/* offline — fall through to the cached profile, if any */
	}
	try {
		const cached = localStorage.getItem(cacheKey);
		if (cached) machineProfile = JSON.parse(cached);
	} catch {
		/* no cached profile available; ledger falls back to pace 1.0 (reference) */
	}
}

function renderMachineProfileNote() {
	const el = document.getElementById("machine-profile-note");
	if (!el) return;
	const pace = machineProfile?.pace;
	if (pace?.source === "override") {
		el.textContent = `Pace ×${pace.value.toFixed(2)} — your manual tweak${pace.roasterName ? ` for ${pace.roasterName}` : ""} (Machines page).`;
		return;
	}
	if (!pace || pace.source !== "learned") {
		el.textContent =
			"Using reference timing (one operator's Hottop KN-8828B-2K+) — recalibrates to your own machine as you log roasts with an actual first-crack time.";
		return;
	}
	const paceText =
		Math.abs(pace.value - 1) < 0.01
			? "running close to reference pace"
			: `running ${pace.value > 1 ? `${Math.round((pace.value - 1) * 100)}% slower` : `${Math.round((1 - pace.value) * 100)}% faster`} than reference`;
	el.textContent = `Learned from ${pace.n} of your own roasts — ${paceText}.`;
}

// Temperature fields are always stored in state.plan as canonical °C — this is purely a
// display-layer preference. tempC/actualBt/expectedBt cover Machine Plan, Roast Log, and
// the actuator schedule respectively; every other numeric field (%, g, days) is untouched.
const TEMP_UNIT_KEY = "roastPlannerTempUnit.v1";
const TEMP_FIELD_RE = /\.(tempC|actualBt|expectedBt)$/;
let tempUnit = localStorage.getItem(TEMP_UNIT_KEY) === "F" ? "F" : "C";
const cToF = (c) => (c * 9) / 5 + 32;
const fToC = (f) => ((f - 32) * 5) / 9;
function isTempField(name) {
	return TEMP_FIELD_RE.test(name);
}
// Canonical °C string -> what the field should display right now.
function tempToDisplay(canonical) {
	const n = Number.parseFloat(canonical);
	if (!Number.isFinite(n)) return canonical ?? "";
	return String(Math.round(tempUnit === "F" ? cToF(n) : n));
}
// What the user typed (in the current display unit) -> canonical °C string.
function tempToCanonical(display) {
	const n = Number.parseFloat(display);
	if (!Number.isFinite(n)) return display;
	return String(Math.round((tempUnit === "F" ? fToC(n) : n) * 10) / 10);
}

export const state = { plan: blankPlan() };

const form = document.getElementById("plan-form");

// The print worksheet (#print-sheet) sits outside #plan-form on purpose — see index.html —
// so its radios don't fight the screen form's identically-named radios for exclusivity.
// Every sync pass therefore has to reach both containers explicitly.
function allNamedInputs() {
	return document.querySelectorAll(
		"#plan-form input, #plan-form select, #plan-form textarea, " +
			"#print-sheet input, #print-sheet select, #print-sheet textarea",
	);
}

// ---- nested path get/set for names like "temps.charge.tempC" or "blendComponents.0.cultivar"
function getPath(obj, path) {
	return path.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);
}
function setPath(obj, path, value) {
	const parts = path.split(".");
	let node = obj;
	for (let i = 0; i < parts.length - 1; i++) {
		if (node[parts[i]] === undefined || node[parts[i]] === null)
			node[parts[i]] = {};
		node = node[parts[i]];
	}
	node[parts[parts.length - 1]] = value;
}

function valueForName(name) {
	if (FIELD_ID_SET.has(name)) return state.plan.fields[name] ?? "";
	const v = getPath(state.plan, name);
	return v ?? "";
}

function setValueForName(name, value) {
	if (FIELD_ID_SET.has(name)) {
		state.plan.fields[name] = value;
	} else {
		setPath(state.plan, name, value);
	}
}

// ---- dynamic rows: blend components + actuator schedule
// Each renders into TWO places that share `name` attributes: the interactive screen
// component, and the hidden print-only worksheet table (#blend-rows / #actuator-rows),
// which is kept in sync by renderFormFromPlan() and only needs to look right on paper.
function renderBlendPrintRows() {
	const tbody = document.getElementById("blend-rows");
	tbody.replaceChildren();
	state.plan.blendComponents.forEach((_, i) => {
		const tr = document.createElement("tr");
		tr.append(
			document.createRange().createContextualFragment(`
      <td><input class="f w ws-input" name="blendComponents.${i}.cultivar"><span class="pv"></span></td>
      <td><input class="f ws-input" style="min-width:18mm" name="blendComponents.${i}.group"><span class="pv"></span></td>
      <td><input class="f ws-input" style="min-width:20mm" name="blendComponents.${i}.process"><span class="pv"></span></td>
      <td class="num"><input class="f n ws-input" name="blendComponents.${i}.sharePct"><span class="pv"></span></td>
      <td class="num"><input class="f n ws-input" name="blendComponents.${i}.fcAnchor"><span class="pv"></span></td>
    `),
		);
		tbody.appendChild(tr);
	});
}

function renderBlendCards() {
	const wrap = document.getElementById("blend-cards");
	wrap.replaceChildren();
	state.plan.blendComponents.forEach((_, i) => {
		const card = document.createElement("div");
		card.className = "blend-card";
		card.append(
			document.createRange().createContextualFragment(`
      <label class="field"><span class="field-label">Cultivar</span><input class="field-input" name="blendComponents.${i}.cultivar" placeholder="e.g. Caturra"></label>
      <label class="field"><span class="field-label">Group</span><input class="field-input" name="blendComponents.${i}.group"></label>
      <label class="field"><span class="field-label">Process</span><input class="field-input" name="blendComponents.${i}.process"></label>
      <label class="field"><span class="field-label">Share %</span><input class="field-input sm" name="blendComponents.${i}.sharePct"></label>
      <label class="field"><span class="field-label">FC anchor</span><input class="field-input sm" name="blendComponents.${i}.fcAnchor"></label>
      <button type="button" class="blend-remove" data-remove-blend="${i}" aria-label="Remove component" ${state.plan.blendComponents.length <= 1 ? "disabled" : ""}>✕</button>
    `),
		);
		wrap.appendChild(card);
	});
	for (const btn of wrap.querySelectorAll("[data-remove-blend]")) {
		btn.addEventListener("click", () => {
			const i = Number(btn.dataset.removeBlend);
			if (state.plan.blendComponents.length <= 1) return;
			state.plan.blendComponents.splice(i, 1);
			renderBlend();
			renderFormFromPlan();
			recompute();
		});
	}
}

function updateBlendTotal() {
	const total = state.plan.blendComponents.reduce(
		(sum, c) => sum + (Number.parseFloat(c.sharePct) || 0),
		0,
	);
	const fill = document.getElementById("blend-total-fill");
	const label = document.getElementById("blend-total-label");
	if (!fill || !label) return;
	fill.style.width = `${Math.min(100, total)}%`;
	fill.classList.toggle("over", total > 100);
	fill.classList.toggle("under", total > 0 && total < 100);
	label.textContent = `${Math.round(total * 10) / 10}% of 100%`;
}

function updateBlendVisibility(mode) {
	document
		.getElementById("blend-body")
		.classList.toggle("collapsed", mode !== "blend");
}

function renderBlend() {
	renderBlendPrintRows();
	renderBlendCards();
	updateBlendTotal();
}

function renderActuatorPrintRows() {
	const tbody = document.getElementById("actuator-rows");
	tbody.replaceChildren();
	state.plan.actuators.forEach((_, i) => {
		const tr = document.createElement("tr");
		tr.append(
			document.createRange().createContextualFragment(`
      <td class="num"><input class="f n ws-input" name="actuators.${i}.time"><span class="pv"></span></td>
      <td class="num"><input class="f n ws-input" name="actuators.${i}.heatPct"><span class="pv"></span></td>
      <td class="num"><input class="f n ws-input" name="actuators.${i}.fanPct"><span class="pv"></span></td>
      <td class="num"><input class="f n ws-input" name="actuators.${i}.expectedBt"><span class="pv"></span></td>
      <td><input class="f ws-input" style="min-width:78mm" name="actuators.${i}.why"><span class="pv"></span></td>
    `),
		);
		tbody.appendChild(tr);
	});
}

function renderActuatorTimeline() {
	const wrap = document.getElementById("actuator-timeline");
	wrap.replaceChildren();
	state.plan.actuators.forEach((_, i) => {
		const step = document.createElement("div");
		step.className = "actuator-step";
		step.append(
			document.createRange().createContextualFragment(`
      <div class="actuator-rail"><div class="actuator-dot"></div><div class="actuator-line"></div></div>
      <div class="actuator-card">
        <label class="field"><span class="field-label">Time</span><input class="field-input sm" name="actuators.${i}.time" placeholder="m:ss"></label>
        <label class="field"><span class="field-label">Heat %</span><input class="field-input sm" name="actuators.${i}.heatPct"></label>
        <label class="field"><span class="field-label">Fan %</span><input class="field-input sm" name="actuators.${i}.fanPct"></label>
        <button type="button" class="actuator-remove" data-remove-actuator="${i}" aria-label="Remove step" ${state.plan.actuators.length <= 1 ? "disabled" : ""}>✕</button>
        <label class="field"><span class="field-label">Expected BT</span><input class="field-input sm" name="actuators.${i}.expectedBt"></label>
        <label class="field actuator-why"><span class="field-label">Why this change</span><input class="field-input" name="actuators.${i}.why" placeholder="What you're watching for"></label>
      </div>
    `),
		);
		wrap.appendChild(step);
	});
	for (const btn of wrap.querySelectorAll("[data-remove-actuator]")) {
		btn.addEventListener("click", () => {
			const i = Number(btn.dataset.removeActuator);
			if (state.plan.actuators.length <= 1) return;
			state.plan.actuators.splice(i, 1);
			renderActuators();
			renderFormFromPlan();
			recompute();
		});
	}
}

function renderActuators() {
	renderActuatorPrintRows();
	renderActuatorTimeline();
	updateTempUnitUI(); // new actuator rows need their expectedBt placeholder set too
}

// ---- populate every control in the form from state.plan
export function renderFormFromPlan() {
	for (const el of allNamedInputs()) {
		const name = el.name;
		if (!name) continue;
		const value = valueForName(name);
		if (el.type === "radio") {
			el.checked = el.value === value;
		} else if (el.type === "checkbox") {
			el.checked = Boolean(value);
		} else {
			el.value = isTempField(name) ? tempToDisplay(value) : (value ?? "");
		}
	}
	updateBlendVisibility(state.plan.fields["2.1"]);
	document.getElementById("header-coffee-name").textContent =
		state.plan.fields["0.1"] || "New plan";
}

function fmtOut(id, text) {
	for (const el of document.querySelectorAll(`[data-out="${id}"]`))
		el.textContent = text ?? "—";
}

function renderLedger() {
	const ledger = computeLedger(state.plan, machineProfile);
	const d = (s) => (s === null || s === undefined ? "—" : formatDuration(s));
	const ds = (s) => (s === null || s === undefined ? "—" : formatSigned(s));

	fmtOut("l1", d(ledger.lines.l1));
	fmtOut("l2", ds(ledger.lines.l2));
	fmtOut("l3", ds(ledger.lines.l3));
	fmtOut("subtotalA", d(ledger.subtotalA));
	fmtOut("pace-factor", `×${ledger.pace.toFixed(2)}`);
	fmtOut("A", d(ledger.A));
	fmtOut("yellow", d(ledger.yellow));
	fmtOut("maillard", d(ledger.maillard));
	fmtOut("l7", d(ledger.lines.l7));
	fmtOut("l8", ds(ledger.lines.l8));
	fmtOut("l9", ds(ledger.lines.l9));
	fmtOut("C", d(ledger.C));
	fmtOut("D", d(ledger.D));

	const pct = (v) => (v === null || v === undefined ? "—" : `${v.toFixed(1)}%`);
	fmtOut("check-drying", pct(ledger.checks.drying.pct));
	fmtOut("check-maillard", pct(ledger.checks.maillard.pct));
	fmtOut("check-dtr", pct(ledger.checks.dtr.pct));
	fmtOut("check-ceiling", d(ledger.checks.ceiling.valueS));

	const warnings = document.getElementById("ledger-warnings");
	warnings.replaceChildren(
		...ledger.warnings.map((warning) => {
			const item = document.createElement("p");
			item.textContent = warning;
			return item;
		}),
	);
	warnings.classList.toggle("hidden", ledger.warnings.length === 0);

	for (const [key, check] of Object.entries(ledger.checks)) {
		for (const cell of document.querySelectorAll(`[data-pass="${key}"]`)) {
			cell.classList.remove("pass", "fail", "unknown", "informational");
			// Drying/Maillard are mathematically derived from DTR (yellow = 0.56 × A), so they can
			// never fail independently of it — shown as plain figures, not a pass/fail verdict.
			if (check.informational) cell.classList.add("informational");
			else
				cell.classList.add(
					check.pass === null ? "unknown" : check.pass ? "pass" : "fail",
				);
		}
	}

	// Box 8 / box 11 derived time columns
	fmtOut("t-charge", "0:00");
	fmtOut("t-yellow", d(ledger.yellow));
	fmtOut("t-fc", d(ledger.A));
	fmtOut("t-drop", d(ledger.D));
	fmtOut("pa-charge", "0:00");
	fmtOut("pa-tp", state.plan.temps.tp.time || "—");
	fmtOut("pa-yellow", d(ledger.yellow));
	fmtOut("pa-fc", d(ledger.A));
	fmtOut("pa-drop", d(ledger.D));

	document.getElementById("back-coffee-name").textContent =
		state.plan.fields["0.1"] || "";
	document.getElementById("header-coffee-name").textContent =
		state.plan.fields["0.1"] || "New plan";

	return ledger;
}

function domainPct(v) {
	const [lo, hi] = BAND_DOMAIN;
	return Math.max(0, Math.min(100, ((v - lo) / (hi - lo)) * 100));
}

function renderBandRanges() {
	for (const row of document.querySelectorAll(".band-row")) {
		const lo = Number(row.dataset.bandLo);
		const hi = Number(row.dataset.bandHi);
		const range = row.querySelector(".band-range");
		if (!range) continue;
		const loPct = domainPct(lo);
		range.style.left = `${loPct}%`;
		range.style.width = `${domainPct(hi) - loPct}%`;
	}
}

function renderBandMarkers() {
	for (const row of document.querySelectorAll(".band-row")) {
		const input = row.querySelector('input[name$=".tempC"]');
		const marker = row.querySelector(".band-marker");
		if (!input || !marker) continue;
		const raw = Number.parseFloat(input.value);
		const v = Number.isFinite(raw) ? (tempUnit === "F" ? fToC(raw) : raw) : NaN;
		if (!Number.isFinite(v)) {
			marker.style.display = "none";
			continue;
		}
		const lo = Number(row.dataset.bandLo);
		const hi = Number(row.dataset.bandHi);
		marker.style.display = "block";
		marker.style.left = `${domainPct(v)}%`;
		marker.classList.toggle("out-of-band", v < lo || v > hi);
	}
}

function paintCurveInto(planGroup, refGroup, planPoints, ref) {
	planGroup.replaceChildren();
	if (planPoints.length > 0) {
		const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
		path.setAttribute("d", pointsToPathD(planPoints));
		path.setAttribute("fill", "none");
		path.setAttribute("stroke", "currentColor");
		path.setAttribute("stroke-width", "1.6");
		path.style.color = "var(--ink, #1a1512)";
		planGroup.appendChild(path);
		for (const p of planPoints) {
			const c = document.createElementNS(
				"http://www.w3.org/2000/svg",
				"circle",
			);
			c.setAttribute("cx", tToX(p.timeS).toFixed(1));
			c.setAttribute("cy", tempToY(p.tempC).toFixed(1));
			c.setAttribute("r", "3.2");
			c.setAttribute("fill", "currentColor");
			c.style.color = "var(--ink, #1a1512)";
			planGroup.appendChild(c);
		}
	}

	refGroup.replaceChildren();
	if (ref?.curve?.length) {
		const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
		const d = ref.curve
			.map(
				(p, i) =>
					`${i === 0 ? "M" : "L"}${tToX(p.t).toFixed(1)},${tempToY(p.bt).toFixed(1)}`,
			)
			.join(" ");
		path.setAttribute("d", d);
		path.setAttribute("fill", "none");
		path.setAttribute("stroke", "currentColor");
		path.setAttribute("stroke-width", "1.3");
		path.setAttribute("stroke-dasharray", "4 3");
		path.setAttribute("opacity", ".65");
		path.style.color = "var(--ink-2, #5a5048)";
		refGroup.appendChild(path);
		for (const m of ref.milestones ?? []) {
			if (m.tempC === null) continue;
			const c = document.createElementNS(
				"http://www.w3.org/2000/svg",
				"circle",
			);
			c.setAttribute("cx", tToX(m.timeS).toFixed(1));
			c.setAttribute("cy", tempToY(m.tempC).toFixed(1));
			c.setAttribute("r", "2.6");
			c.setAttribute("fill", "none");
			c.setAttribute("stroke", "currentColor");
			c.setAttribute("stroke-width", "1.2");
			c.style.color = "var(--ink-2, #5a5048)";
			refGroup.appendChild(c);
		}
	}
}

function renderCurve(ledger) {
	const planPoints = buildPlanCurve(state.plan, ledger, roasterProfile);
	const ref = state.plan.reference;

	paintCurveInto(
		document.getElementById("plan-curve"),
		document.getElementById("ref-curve"),
		planPoints,
		ref,
	);
	paintCurveInto(
		document.getElementById("plan-curve-live"),
		document.getElementById("ref-curve-live"),
		planPoints,
		ref,
	);
}

// The blank shape has some non-empty defaults of its own (e.g. planActual.charge.actualTime
// is "0:00", since charge is always t=0) — compare against those defaults, not against "",
// so a fresh plan doesn't read as already having roast-day data.
const BLANK_PLAN = blankPlan();

// True once any value in `obj` differs from the same path in `blank` — used to lift the
// "phase-later" muting off Roast Log / After the Roast the moment the user has actually started
// using them, without ever hiding the plan column they exist to compare against.
function hasVal(obj, blank) {
	if (obj == null) return false;
	return Object.keys(obj).some((key) => {
		const v = obj[key];
		return typeof v === "object" && v !== null
			? hasVal(v, blank?.[key])
			: v !== "" && v != null && v !== blank?.[key];
	});
}

export function recompute() {
	const ledger = renderLedger();
	renderCurve(ledger);
	renderBandMarkers();
	document
		.getElementById("sec-roastlog")
		?.classList.toggle(
			"has-data",
			hasVal(state.plan.planActual, BLANK_PLAN.planActual),
		);
	lotPicker?.updateConsumeRow();
	autosave();
}

function setSaveStatus(status) {
	const chip = document.getElementById("autosave-status");
	const text = chip.querySelector(".autosave-text");
	chip.classList.remove("saving", "saved", "failed");
	chip.classList.add(status);
	text.textContent =
		{
			saving: "Saving locally…",
			saved: "Synced",
			failed: "Saved locally — sync failed",
			local: "Saved locally — waiting to sync",
		}[status] || "Not saved yet";
}
let syncQueue = Promise.resolve();
// Resolves true once the server sync attempt has settled (succeeded, failed, or was correctly
// deferred because we're offline/unauthenticated) — offline is not a failure here, the local
// write it's paired with in flushCurrentPlan already guarantees the draft isn't lost, and
// newPlan()/selectPlan() rely on that to let plan-switching keep working offline. Callers that
// specifically need a confirmed server-side plan id (attaching a lot draw or a cupping session)
// must check remotePlanId themselves afterward, which they already do.
function syncPlan(snapshot = structuredClone(state.plan)) {
	if (!navigator.onLine || !csrfToken()) {
		setSaveStatus("local");
		return Promise.resolve(true);
	}
	syncQueue = syncQueue.then(async () => {
		try {
			const response = await protectedFetch(
				remotePlanId ? `/api/plans/${remotePlanId}` : "/api/plans",
				{
					method: remotePlanId ? "PUT" : "POST",
					headers: { "content-type": "application/json" },
					body: JSON.stringify({ plan: snapshot }),
				},
			);
			if (!response.ok) throw new Error("sync_failed");
			const body = await response.json();
			remotePlanId = body.plan.id;
			draftSyncedAt = body.plan.updated_at;
			if (storageKey)
				localStorage.setItem(
					storageKey,
					JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
				);
			history.replaceState(
				null,
				"",
				`/app?plan=${encodeURIComponent(remotePlanId)}`,
			);
			setSaveStatus("saved");
			await loadPlans();
			return true;
		} catch {
			setSaveStatus("failed");
			return false;
		}
	});
	return syncQueue;
}
async function flushCurrentPlan() {
	clearTimeout(autosave._t);
	const snapshot = structuredClone(state.plan);
	try {
		if (storageKey)
			localStorage.setItem(
				storageKey,
				JSON.stringify({ plan: snapshot, remotePlanId, syncedAt: draftSyncedAt }),
			);
	} catch {
		setSaveStatus("failed");
		return false;
	}
	return await syncPlan(snapshot);
}
function autosave() {
	setSaveStatus("saving");
	clearTimeout(autosave._t);
	autosave._t = setTimeout(() => {
		flushCurrentPlan();
	}, 400);
}

function loadFromStorage(userId) {
	try {
		storageKey = `${STORAGE_PREFIX}:${userId}`;
		localStorage.setItem(`${STORAGE_PREFIX}:last-user`, userId);
		const machineProfileKey = `${MACHINE_PROFILE_KEY_PREFIX}:${userId}`;
		// A shared browser must never retain a previous account's local-only draft or cache.
		for (let i = localStorage.length - 1; i >= 0; i--) {
			const key = localStorage.key(i);
			if (
				(key?.startsWith(`${STORAGE_PREFIX}:`) &&
					key !== storageKey &&
					key !== `${STORAGE_PREFIX}:last-user`) ||
				key === "roastPlannerPlan.v1" ||
				(key?.startsWith(`${MACHINE_PROFILE_KEY_PREFIX}:`) && key !== machineProfileKey)
			)
				localStorage.removeItem(key);
		}
		const raw = localStorage.getItem(storageKey);
		if (!raw) return null;
		const draft = JSON.parse(raw);
		if (draft?.plan) {
			remotePlanId = draft.remotePlanId || null;
			draftSyncedAt = draft.syncedAt || null;
			return draft.plan;
		}
		return draft; // legacy v2 draft: retain it once, then upgrade on next save
	} catch {
		return null;
	}
}

function clearDraft() {
	if (storageKey) localStorage.removeItem(storageKey);
	try {
		localStorage.removeItem(`${STORAGE_PREFIX}:last-user`);
		for (let i = localStorage.length - 1; i >= 0; i--) {
			const key = localStorage.key(i);
			if (key?.startsWith(`${MACHINE_PROFILE_KEY_PREFIX}:`)) localStorage.removeItem(key);
		}
	} catch {
		/* unavailable storage */
	}
	storageKey = null;
	remotePlanId = null;
	draftSyncedAt = null;
}

function cultivarAutofill(name) {
	const row = findCultivar(name);
	if (!row) return;
	setValueForName("1.2", row.group);
	setValueForName("1.4", formatDuration(parseRangeMidpoint(row.fcAnchor)));
	setValueForName("1.5", row.profile.join("|"));
	setValueForName("1.7", formatSigned(row.devModS));
	renderFormFromPlan();
}

// Mirrors cultivarAutofill(): picking a process/roast level should feed the same reference
// numbers into the ledger that selecting a cultivar does, not just record the choice as text.
function processAutofill(key) {
	const row = findProcess(key);
	if (!row) return;
	setValueForName("3.2", formatSigned(row.devModS));
	renderFormFromPlan();
}

function roastLevelAutofill(key) {
	const row = findRoastLevel(key);
	if (!row) return;
	setValueForName("4.3", formatDuration(parseRangeMidpoint(row.devBand)));
	renderFormFromPlan();
}

function wireCultivarDatalist() {
	const list = document.getElementById("cultivar-list");
	list.replaceChildren(
		...CULTIVARS.map((c) => {
			const option = document.createElement("option");
			option.value = c.name;
			return option;
		}),
	);
}

function wireForm() {
	form.addEventListener("input", (e) => {
		const el = e.target;
		if (!el.name) return;
		if (el.type === "radio" && !el.checked) return;
		setValueForName(
			el.name,
			isTempField(el.name) ? tempToCanonical(el.value) : el.value,
		);
		if (el.name === "1.1") cultivarAutofill(el.value);
		if (el.name === "2.1") updateBlendVisibility(el.value);
		if (el.name === "3.1") processAutofill(el.value);
		if (el.name === "4.1") roastLevelAutofill(el.value);
		if (el.name.startsWith("blendComponents.")) updateBlendTotal();
		recompute();
	});
}

// Refreshes every unit-facing bit of chrome (labels, placeholders, the toggle itself) after
// tempUnit changes. Field *values* are handled separately by renderFormFromPlan().
function updateTempUnitUI() {
	const label = tempUnit === "F" ? "°F" : "°C";
	for (const input of document.querySelectorAll(
		'input[name$=".tempC"], input[name$=".actualBt"], input[name$=".expectedBt"]',
	))
		input.placeholder = label;
	for (const span of document.querySelectorAll("#sec-machine .unit-input .unit"))
		span.textContent = label;
	const printHeader = document.getElementById("print-machine-temp-th");
	if (printHeader) printHeader.textContent = label;
	for (const input of document.querySelectorAll('#temp-unit-toggle input[name="tempUnit"]'))
		input.checked = input.value === tempUnit;

	// Static reference numbers that live outside the plan (chart axes, band captions, the
	// print worksheet's "Your logged band" column) — these are fixed constants, not plan
	// data, so they're driven off data-tempc/data-band-lo/hi rather than valueForName().
	const conv = (c) => Math.round(tempUnit === "F" ? cToF(c) : c);
	for (const el of document.querySelectorAll("[data-tempc]"))
		el.textContent = String(conv(Number(el.dataset.tempc)));
	for (const el of document.querySelectorAll("[data-band-lo]")) {
		const lo = conv(Number(el.dataset.bandLo));
		const hi = conv(Number(el.dataset.bandHi));
		el.textContent = `${el.dataset.bandPrefix ?? ""}${lo}–${hi} ${label}${el.dataset.bandSuffix ?? ""}`;
	}
	const axisTitle = document.getElementById("print-curve-axis-title");
	if (axisTitle) axisTitle.textContent = `BEAN TEMP ${label}`;
}

function setTempUnit(unit) {
	tempUnit = unit === "F" ? "F" : "C";
	localStorage.setItem(TEMP_UNIT_KEY, tempUnit);
	updateTempUnitUI();
	renderFormFromPlan();
	recompute();
}

function wireTempUnitToggle() {
	const toggle = document.getElementById("temp-unit-toggle");
	if (!toggle) return;
	toggle.addEventListener("change", (e) => {
		if (e.target.name === "tempUnit") setTempUnit(e.target.value);
	});
}

function wireDrawers() {
	const overlay = document.getElementById("drawer-overlay");
	const drawers = [...document.querySelectorAll(".drawer")];
	function closeAll() {
		for (const panel of drawers) {
			panel.classList.add("hidden");
			panel.setAttribute("aria-hidden", "true");
		}
		overlay.classList.add("hidden");
		lastDrawerOpener?.focus();
	}
	function open(panel, opener) {
		lastDrawerOpener = opener;
		for (const p of drawers) {
			p.classList.add("hidden");
			p.setAttribute("aria-hidden", "true");
		}
		panel.classList.remove("hidden");
		panel.setAttribute("aria-hidden", "false");
		overlay.classList.remove("hidden");
		panel.querySelector("button, input, [href]")?.focus();
	}
	for (const [buttonId, panelId] of [
		["nav-prefill", "panel-prefill"],
		["nav-alog", "panel-alog"],
		["nav-plans", "panel-plans"],
		["nav-chat", "panel-chat"],
		["nav-settings", "panel-settings"],
		["nav-import-export", "panel-import-export"],
	])
		document
			.getElementById(buttonId)
			.addEventListener("click", (event) => {
				document.getElementById("side-nav")?.classList.remove("mobile-open");
				open(document.getElementById(panelId), event.currentTarget);
			});
	overlay.addEventListener("click", closeAll);
	for (const btn of document.querySelectorAll("[data-close-drawer]"))
		btn.addEventListener("click", closeAll);
	document.addEventListener("keydown", (event) => {
		const activeDrawer = drawers.find(
			(drawer) => !drawer.classList.contains("hidden"),
		);
		if (!activeDrawer) return;
		if (event.key === "Escape") {
			closeAll();
			return;
		}
		if (event.key !== "Tab") return;
		const focusable = [
			...activeDrawer.querySelectorAll(
				"button:not([disabled]), input:not([disabled]), [href]",
			),
		];
		const first = focusable[0],
			last = focusable.at(-1);
		if (event.shiftKey && document.activeElement === first) {
			event.preventDefault();
			last?.focus();
		} else if (!event.shiftKey && document.activeElement === last) {
			event.preventDefault();
			first?.focus();
		}
	});
	return { open, closeAll };
}

async function loadPlans() {
	if (!navigator.onLine) return;
	const response = await fetch("/api/plans");
	if (!response.ok) return;
	plans = (await response.json()).plans || [];
	const list = document.getElementById("plan-list");
	list.replaceChildren(
		...plans.map((plan) => {
			const item = document.createElement("li");
			item.className = "plan-list-row";
			const button = document.createElement("button");
			button.type = "button";
			button.className = "plan-list-item";
			button.classList.toggle("active", plan.id === remotePlanId);
			const title = document.createElement("strong");
			title.textContent = plan.plan?.fields?.["0.1"] || "Untitled plan";
			const updated = document.createElement("span");
			updated.textContent = `Updated ${new Date(plan.updated_at).toLocaleDateString()}`;
			button.append(title, updated);
			button.addEventListener("click", () => selectPlan(plan));

			const deleteBtn = document.createElement("button");
			deleteBtn.type = "button";
			deleteBtn.className = "icon-btn plan-list-delete";
			deleteBtn.setAttribute("aria-label", `Delete ${title.textContent}`);
			deleteBtn.textContent = "✕";
			// A sibling of the select button, not nested inside it — stopPropagation alone
			// wouldn't be enough to keep a click here from also selecting the plan otherwise.
			deleteBtn.addEventListener("click", (event) => {
				event.stopPropagation();
				deletePlan(plan);
			});

			item.append(button, deleteBtn);
			return item;
		}),
	);
}
async function selectPlan(plan) {
	if (!(await flushCurrentPlan())) return;
	remotePlanId = plan.id;
	draftSyncedAt = plan.updated_at;
	state.plan = { ...blankPlan(), ...plan.plan };
	history.replaceState(
		null,
		"",
		`/app?plan=${encodeURIComponent(remotePlanId)}`,
	);
	renderBlend();
	renderActuators();
	renderFormFromPlan();
	recompute();
	// renderFormFromPlan() sets the lot <select>'s value, but that only sticks if the option is
	// already in the DOM — re-render the picker's own option list against the newly-loaded
	// plan's inventory.lotId so switching to a plan referencing a different (or no) lot doesn't
	// leave the dropdown showing the previous plan's selection or a blank state.
	lotPicker?.renderOptions();
	lotPicker?.refreshRefineSuggestion();
	document.querySelector("[data-close-drawer]")?.click();
}
// Shared by newPlan() and deletePlan() (when the deleted plan was the one on screen) — resets
// the whole app to a fresh, unsynced plan.
function resetToBlankPlan() {
	remotePlanId = null;
	draftSyncedAt = null;
	state.plan = blankPlan();
	history.replaceState(null, "", "/app");
	renderBlend();
	renderActuators();
	renderFormFromPlan();
	recompute();
	lotPicker?.renderOptions();
	lotPicker?.refreshRefineSuggestion();
}
async function newPlan() {
	if (!confirm("Start a new plan? Your current plan is already saved locally."))
		return;
	if (!(await flushCurrentPlan())) return;
	resetToBlankPlan();
	// Without this, the Plans drawer stays open over the now-blank form — the reset happens but
	// is invisible, so it doesn't read as "starting something new" until the user closes it themselves.
	document.querySelector("[data-close-drawer]")?.click();
}
async function deletePlan(plan) {
	const name = plan.plan?.fields?.["0.1"] || "Untitled plan";
	if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
	try {
		await api(`/api/plans/${plan.id}`, { method: "DELETE" });
	} catch (error) {
		showToast(error.message, "fail");
		return;
	}
	showToast("Plan deleted.");
	if (plan.id === remotePlanId) resetToBlankPlan();
	await loadPlans();
}

// Mobile-only "Tools ▾" disclosure in the header; a no-op on desktop where the group renders
// inline (display: contents). Any action inside closes it — every tool opens a drawer anyway.
function wireToolsMenu() {
	const toggle = document.getElementById("btn-tools-menu");
	const group = document.getElementById("header-tools-group");
	if (!toggle || !group) return;
	const close = () => {
		group.classList.remove("open");
		toggle.setAttribute("aria-expanded", "false");
	};
	toggle.addEventListener("click", (event) => {
		event.stopPropagation();
		const open = group.classList.toggle("open");
		toggle.setAttribute("aria-expanded", String(open));
	});
	group.addEventListener("click", (event) => {
		if (event.target.closest("button")) close();
	});
	document.addEventListener("click", (event) => {
		if (!group.classList.contains("open")) return;
		if (!group.contains(event.target) && event.target !== toggle) close();
	});
	document.addEventListener("keydown", (event) => {
		if (event.key === "Escape") close();
	});
}

function wireToolbar() {
	document.getElementById("btn-toggle-fids").addEventListener("click", (e) => {
		const on = document.body.classList.toggle("show-fids");
		e.currentTarget.setAttribute("aria-pressed", String(on));
	});

	document.getElementById("btn-save").addEventListener("click", () => {
		const blob = new Blob([JSON.stringify(state.plan, null, 2)], {
			type: "application/json",
		});
		const a = document.createElement("a");
		a.href = URL.createObjectURL(blob);
		a.download = `${(state.plan.fields["0.1"] || "roast-plan").replace(/[^\w-]+/g, "_")}.json`;
		a.click();
		URL.revokeObjectURL(a.href);
	});
	document
		.getElementById("btn-load")
		.addEventListener("click", () =>
			document.getElementById("file-load").click(),
		);
	document.getElementById("file-load").addEventListener("change", async (e) => {
		const file = e.target.files?.[0];
		if (!file) return;
		try {
			const loaded = JSON.parse(await file.text());
			state.plan = { ...blankPlan(), ...loaded };
			renderBlend();
			renderActuators();
			renderFormFromPlan();
			recompute();
		} catch (err) {
			alert(`Could not load plan: ${err.message}`);
		}
	});

	document.getElementById("btn-new-plan").addEventListener("click", newPlan);
	document.getElementById("btn-add-blend").addEventListener("click", () => {
		state.plan.blendComponents.push({
			cultivar: "",
			group: "",
			process: "",
			sharePct: "",
			fcAnchor: "",
		});
		renderBlend();
		renderFormFromPlan();
		recompute();
	});
	document.getElementById("btn-logout").addEventListener("click", async () => {
		try {
			await protectedFetch("/api/auth/logout", { method: "POST" });
		} finally {
			clearDraft();
			location.replace("/");
		}
	});
	document.getElementById("btn-add-actuator").addEventListener("click", () => {
		state.plan.actuators.push({
			time: "",
			heatPct: "",
			fanPct: "",
			expectedBt: "",
			why: "",
		});
		renderActuators();
		renderFormFromPlan();
		recompute();
	});
}

function wirePwa() {
	const status = document.getElementById("connection-status");
	const renderConnection = () => {
		const offline = !navigator.onLine;
		status.classList.toggle("hidden", !offline);
		status.textContent = offline
			? "Offline — changes continue saving on this device."
			: "";
	};

	window.addEventListener("online", () => {
		renderConnection();
		syncPlan();
	});
	window.addEventListener("offline", renderConnection);
	window.addEventListener("beforeinstallprompt", (event) => {
		event.preventDefault();
		deferredInstallPrompt = event;
		document.getElementById("btn-install").classList.remove("hidden");
	});
	document.getElementById("btn-install").addEventListener("click", async () => {
		if (!deferredInstallPrompt) return;
		deferredInstallPrompt.prompt();
		await deferredInstallPrompt.userChoice;
		deferredInstallPrompt = null;
		document.getElementById("btn-install").classList.add("hidden");
	});
	renderConnection();

	if ("serviceWorker" in navigator) {
		// A brand-new visitor has no controller yet, so the *first* activation firing
		// "controllerchange" is not an update — only a page that was already controlled by a
		// previous service worker should reload when a new one takes over.
		const hadController = !!navigator.serviceWorker.controller;
		window.addEventListener("load", () => {
			navigator.serviceWorker
				.register("/sw.js?v=__ASSET_VERSION__")
				.then((registration) => {
					const showUpdate = () =>
						document.getElementById("btn-refresh").classList.remove("hidden");
					if (registration.waiting) showUpdate();
					registration.addEventListener("updatefound", () =>
						registration.installing?.addEventListener("statechange", () => {
							if (registration.waiting && navigator.serviceWorker.controller)
								showUpdate();
						}),
					);
				})
				.catch((error) =>
					console.warn("Service worker registration failed:", error),
				);
		});
		navigator.serviceWorker.addEventListener("controllerchange", () => {
			if (hadController) location.reload();
		});
	}
	document.getElementById("btn-refresh").addEventListener("click", async () => {
		if (!(await flushCurrentPlan())) return;
		const registration = await navigator.serviceWorker.getRegistration();
		registration?.waiting?.postMessage("SKIP_WAITING");
	});
}

function wireSectionNav() {
	const links = [...document.querySelectorAll(".section-nav a")];
	const sections = links
		.map((a) => document.querySelector(a.getAttribute("href")))
		.filter(Boolean);
	if (sections.length === 0) return;

	const observer = new IntersectionObserver(
		(entries) => {
			for (const entry of entries) {
				if (!entry.isIntersecting) continue;
				const id = `#${entry.target.id}`;
				for (const a of links) {
					const active = a.getAttribute("href") === id;
					a.classList.toggle("active", active);
					a.toggleAttribute("aria-current", active);
				}
			}
		},
		{ rootMargin: "-20% 0px -70% 0px" },
	);
	for (const s of sections) observer.observe(s);
}

async function init() {
	// The side nav is generated — render it before anything touches its elements (the admin
	// link reveal below crashed init for admin accounts when this ran later).
	initSideNav();
	try {
		const meResponse = await fetch("/api/auth/me");
		if (!meResponse.ok) {
			location.replace("/");
			return;
		}
		const { user } = await meResponse.json();
		document.getElementById("account-email").textContent = user.email;
		document.getElementById("settings-email").textContent = user.email;
		applyAvatar(document.getElementById("nav-user-avatar"), user);
		if (user.role === "admin")
			document.getElementById("nav-admin")?.classList.remove("hidden");
		const localDraft = loadFromStorage(user.id);
		state.plan = localDraft ?? blankPlan();
		const draftRemoteId = remotePlanId;
		const draftSyncedAtAtLoad = draftSyncedAt;
		// Roaster profile loads after the machine profile on purpose: its pace override (if
		// any) must be applied on top of the freshly loaded machine profile, not raced with it.
		await Promise.all([loadPlans(), loadMachineProfile(user.id)]);
		await loadRoasterProfile();
		const requestedId = new URLSearchParams(location.search).get("plan");
		const selected = plans.find((plan) => plan.id === requestedId);
		// Prefer the local draft only when it targets this exact plan AND was last confirmed
		// synced at or after the server's own updated_at — otherwise another device's newer
		// edit (or a draft from before sync tracking existed) would be silently discarded.
		const draftIsCurrent =
			selected &&
			draftRemoteId === selected.id &&
			draftSyncedAtAtLoad &&
			new Date(selected.updated_at) <= new Date(draftSyncedAtAtLoad);
		if (selected && !draftIsCurrent) {
			state.plan = { ...blankPlan(), ...selected.plan };
			remotePlanId = selected.id;
			draftSyncedAt = selected.updated_at;
		} else if (selected) {
			remotePlanId = selected.id;
		} else if (!localDraft && !requestedId && plans[0]) {
			state.plan = { ...blankPlan(), ...plans[0].plan };
			remotePlanId = plans[0].id;
			draftSyncedAt = plans[0].updated_at;
		}
	} catch {
		// The cached app shell contains no user data. A successful online sign-in records the
		// last account only until logout, allowing that account's local draft to reopen offline.
		try {
			const offlineUserId = localStorage.getItem(`${STORAGE_PREFIX}:last-user`);
			if (offlineUserId && csrfToken()) {
				state.plan = loadFromStorage(offlineUserId) ?? blankPlan();
				await loadMachineProfile(offlineUserId);
			}
		} catch {
			/* no local draft is available */
		}
	}
	renderBlend();
	renderActuators();
	wireCultivarDatalist();
	wireWhyPanels();
	initFieldHelp({
		getPlan: () => state.plan,
		getTempUnit: () => tempUnit,
		getMachineProfile: () => machineProfile,
	});
	renderBandRanges();
	renderFormFromPlan();
	wireForm();
	wireDrawers();
	wireToolbar();
	wireToolsMenu();
	wireTempUnitToggle();
	updateTempUnitUI();
	wirePwa();
	wireSectionNav();
	initPrefillPanel({
		state,
		renderBlendRows: renderBlend,
		renderActuatorRows: renderActuators,
		renderFormFromPlan,
		recompute,
	});
	initAlogPanel({
		state,
		recompute,
		getRemotePlanId: () => remotePlanId,
		flushCurrentPlan,
	});
	initPrint({ beforePrint: renderFormFromPlan });
	lotPicker = initLotPicker({
		state,
		recompute,
		flushCurrentPlan,
		getRemotePlanId: () => remotePlanId,
		renderFormFromPlan,
	});
	wireCuppingLink();
	initPlanChat({ getPlan: () => state.plan });
	renderMachineProfileNote();
	recompute();
}

function wireCuppingLink() {
	const button = document.getElementById("btn-open-cupping");
	const note = document.getElementById("cupping-link-note");
	// Defensive: this UI lives in the Roast Log section; a missing element must degrade to
	// "no cupping shortcut", never crash init (that failure mode silently killed autosave).
	if (!button || !note) return;
	button.addEventListener("click", async () => {
		button.disabled = true;
		try {
			if (!(await flushCurrentPlan())) {
				note.textContent = "Could not sync the plan. Try again.";
				return;
			}
			if (!remotePlanId) {
				note.textContent = "Sync the plan first, then open a cupping session.";
				return;
			}
			const existing = await protectedFetch(
				`/api/cupping?plan=${encodeURIComponent(remotePlanId)}`,
			)
				.then((r) => (r.ok ? r.json() : { sessions: [] }))
				.catch(() => ({ sessions: [] }));
			if (existing.sessions?.length) {
				location.assign(`/cupping?session=${existing.sessions[0].id}`);
				return;
			}
			let created;
			try {
				created = await protectedFetch("/api/cupping", {
					method: "POST",
					headers: { "content-type": "application/json" },
					body: JSON.stringify({ roastPlanId: remotePlanId }),
				}).then((r) => r.json());
			} catch {
				note.textContent = "Could not reach the server. Try again.";
				return;
			}
			if (created.session) location.assign(`/cupping?session=${created.session.id}`);
			else note.textContent = "Could not start a cupping session.";
		} finally {
			button.disabled = false;
		}
	});
	// Non-blocking: show whether a session already exists without forcing a sync first.
	if (remotePlanId)
		protectedFetch(`/api/cupping?plan=${encodeURIComponent(remotePlanId)}`)
			.then((r) => (r.ok ? r.json() : null))
			.then((body) => {
				if (body?.sessions?.length)
					note.textContent = `Session exists — ${body.sessions[0].totalScore.toFixed(2)}`;
			})
			.catch(() => {});
}

init();
