const { useState, useMemo, useCallback, useEffect, useRef } = React;

let CFG = null;

// subscriptions are billed in full at booking; everything else pays the deposit,
// capped at the total so a booking cheaper than the deposit (repair $15, kids gel
// $25) never overpays (mirrors lib/pricing.js dueNow)
function dueNow(s) {
	return s.visit === "subscription" ? total(s) : Math.min(CFG.deposit, total(s));
}

/* ---------- Appointment report source data (saved before the Square-hosted checkout redirect) ----------
   The acknowledgement is stored as exact text + version, never a bare boolean, so an
   old report shows what THAT customer saw. Server stamps the authoritative UTC time
   and reads the real IP — the client supplies only the booking fields + browser geo. */
const ACK_VERSION = "ack-2026-06-08-v5";
const ACK_TEXT =
	"I understand that charges will appear as LALA LUXE on my bank or card statement. Lalaluxe uses Square to process payments. No payment details are stored. I understand my deposit is non-refundable for cancellations or reschedules made within 24 hours of my appointment.";
const SHARE_IMAGE_LIMIT = 4;
const SHARE_IMAGE_MAX_BYTES = 2 * 1024 * 1024;
const SHARE_IMAGE_MAX_EDGE = 1600;
const SHARE_IMAGE_QUALITY = 0.82;
const SHARE_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);

function formatFileSize(bytes) {
	if (!bytes) return "";
	if (bytes < 1024 * 1024) return Math.ceil(bytes / 1024) + " KB";
	return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}

function renameAsJpeg(name) {
	const clean = String(name || "inspo-image").replace(/\.[^.]+$/, "");
	return (clean || "inspo-image") + ".jpg";
}

function loadImage(file) {
	return new Promise((resolve, reject) => {
		const url = URL.createObjectURL(file);
		const img = new Image();
		img.onload = () => resolve({ img, url });
		img.onerror = () => {
			URL.revokeObjectURL(url);
			reject(new Error("That image could not be read."));
		};
		img.src = url;
	});
}

function canvasBlob(canvas, type, quality) {
	return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
}

async function prepareShareImage(file) {
	if (!file || !SHARE_IMAGE_TYPES.has(file.type))
		throw new Error("Upload JPG, PNG, or WebP images.");
	if (file.size <= SHARE_IMAGE_MAX_BYTES) return file;

	const { img, url } = await loadImage(file);
	try {
		const scale = Math.min(
			1,
			SHARE_IMAGE_MAX_EDGE / Math.max(img.naturalWidth, img.naturalHeight),
		);
		const canvas = document.createElement("canvas");
		canvas.width = Math.max(1, Math.round(img.naturalWidth * scale));
		canvas.height = Math.max(1, Math.round(img.naturalHeight * scale));
		const ctx = canvas.getContext("2d");
		ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
		const blob = await canvasBlob(canvas, "image/jpeg", SHARE_IMAGE_QUALITY);
		if (!blob || blob.size > SHARE_IMAGE_MAX_BYTES)
			throw new Error("Each image must be 2 MB or smaller.");
		return new File([blob], renameAsJpeg(file.name), { type: "image/jpeg" });
	} finally {
		URL.revokeObjectURL(url);
	}
}

async function prepareShareImages(fileList) {
	const files = Array.from(fileList || []).slice(0, SHARE_IMAGE_LIMIT);
	const prepared = [];
	for (const file of files) prepared.push(await prepareShareImage(file));
	return prepared;
}

// High-entropy confirmation number (≈50 bits). A guessable 6-digit id would let anyone
// guess appointment reports before the real booking lands, so we use
// crypto-random unambiguous base32 instead of Math.random().
function newConfirmation() {
	const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; // Crockford base32, no I/L/O/U
	const bytes = new Uint8Array(10);
	crypto.getRandomValues(bytes);
	return "LL" + Array.from(bytes, (b) => alphabet[b & 31]).join("");
}

// Browser geolocation with a hard fallback to IP (resolved server-side). Resolves, never rejects:
// { browser:{lat,lng,accuracy,tsClient}|null, denied:bool }. tsClient is informational only.
function getBrowserGeo() {
	return new Promise((resolve) => {
		if (!navigator.geolocation)
			return resolve({ browser: null, denied: false });
		// The spec's timeout below only counts AFTER the permission prompt is answered;
		// a pending/dismissed prompt would hang forever and hold the payment redirect
		// hostage. Geo is optional evidence — this watchdog caps the total wait.
		setTimeout(() => resolve({ browser: null, denied: false }), 9000);
		navigator.geolocation.getCurrentPosition(
			(p) =>
				resolve({
					browser: {
						lat: p.coords.latitude,
						lng: p.coords.longitude,
						accuracy: p.coords.accuracy,
						tsClient: new Date(p.timestamp).toISOString(),
					},
					denied: false,
				}),
			(err) => resolve({ browser: null, denied: err && err.code === 1 }), // code 1 = permission denied
			{ timeout: 8000, maximumAge: 60000, enableHighAccuracy: false },
		);
	});
}

// Everything the user provided. Card details are intentionally absent: payment happens on Square.
function appointmentReportData(s) {
	const paidNow = dueNow(s),
		grand = total(s);
	return {
		visit: s.visit,
		who: s.who,
		isKid: s.isKid,
		service: s.service,
		length: s.length,
		finish: s.finish,
		design: s.design,
		addons: s.addons,
		tbase: s.tbase,
		tfinish: s.tfinish,
		taddons: s.taddons,
		plan: s.plan,
		subaddons: s.subaddons,
		date: s.date,
		time: s.time,
		contact: {
			name: s.contact?.name || "",
			email: s.contact?.email || "",
			phone: s.contact?.phone || "",
			notes: s.contact?.notes || "",
		},
		amounts: { total: grand, paidNow, balance: Math.max(0, grand - paidNow) },
		card: null,
		serviceSummary:
			priceItems(s)
				.map((it) => it.label)
				.join(", ") || null,
		lineItems: priceItems(s).map((it) => ({ label: it.label, amt: it.amt })),
	};
}

/* ---------- Tile price helper ---------- */
// "add": 0 -> "included", n -> "+$n".  "from": "from $n".  "flat": "$n".
function priceTag(amount, mode = "add") {
	const n = Number(amount) || 0;
	if (mode === "from") return `From $${n}`;
	if (mode === "flat") return `$${n}`;
	return n === 0 ? "Included" : `+$${n}`;
}

// Ordered keys for a list category: the configured order first (existing keys only), then any stragglers.
function orderedKeys(order, map) {
	const o = (order || []).filter((k) => k in map);
	for (const k of Object.keys(map)) if (!o.includes(k)) o.push(k);
	return o;
}

/* ---------- Pricing (reads from CFG after fetch) ---------- */

const TAIL = ["summary", "calendar", "checkout"];
// "finish" (base style) merged into the "design" look screen (2026-06) — one screen, base→art.
const NAILS_STEPS = ["visit", "who", "base", "length", "design", ...TAIL];
const NAILS_TOES_STEPS = [
	"visit",
	"who",
	"base",
	"length",
	"design",
	"tbase",
	"taddons",
	...TAIL,
];
const TOES_STEPS = ["visit", "who", "tbase", "taddons", ...TAIL];
const SUB_STEPS = ["visit", "plan", "subaddons", ...TAIL];
// Existing-subscription redemption (prepaid — no payment/consent). Enter the code, then pick a
// date/time on the shared calendar, then confirm to book one of the remaining 3 visits.
const SUB_REDEEM_STEPS = ["visit", "subcode", "calendar", "subdone"];

function freshState() {
	return {
		step: "intro",
		history: ["intro"],
		visit: null, // appointment | subscription
		isKid: false,
		who: null,
		setType: null, // base-screen fork: "acrylic" | "gel" (gel is the standalone no-acrylic service)
		service: null,
		length: null,
		finish: null,
		design: null,
		addons: [],
		tbase: null,
		tfinish: null,
		taddons: [],
		plan: null,
		subaddons: [],
		// existing-subscription redemption (prepaid, no payment)
		subMode: null, // null (undecided) | "new" | "existing"
		subCode: "", // code entered on the subcode step
		subLookup: null, // { plan, remaining, visitsUsed, visitsAllowed } from /api/subscriptions/lookup
		subResult: null, // { confirmation, remaining } after a successful redeem
		// scheduling
		date: null, // ISO yyyy-mm-dd
		time: null, // e.g. "10:00"
		// checkout
		contact: { name: "", email: "", phone: "", notes: "" },
		shareImages: [],
		square: null, // Square hosted-checkout refs {paymentLinkId,orderId,checkoutUrl,paymentStatus,...}
		confirmation: null, // assigned on pay
		confirmOpen: false,
		acknowledged: false,
		geo: null, // browser geolocation, requested at the consent gesture
	};
}

function computeFlow(s) {
	if (s.visit === "subscription") {
		if (s.subMode === "existing") return SUB_REDEEM_STEPS;
		return SUB_STEPS; // new subscription (default once "new" is chosen)
	}
	if (s.who === "toes") return TOES_STEPS;
	if (s.who === "both") return NAILS_TOES_STEPS;
	return NAILS_STEPS;
}

function priceItems(s) {
	const items = [];
	if (s.visit === "subscription") {
		if (s.plan)
			items.push({ label: CFG.labels.plan[s.plan], amt: CFG.plan[s.plan] });
		s.subaddons.forEach((a) =>
			items.push({ label: CFG.labels.subAddon[a], amt: CFG.subAdd[a] }),
		);
		return items;
	}
	if (s.who === "nails" || s.who === "both") {
		// kind is derived from the base entry's shape: per-length tiers vs { flat: n }
		// (overlay/gel keep their special top-level keys and have no base entry).
		const tiers = CFG.base[s.service] || {};
		if (s.length && tiers[s.length] != null)
			items.push({
				label:
					CFG.labels.service[s.service] + " · " + CFG.labels.length[s.length],
				amt: tiers[s.length],
			});
		else if (s.service === "overlay")
			items.push({ label: "Overlay", amt: CFG.overlay });
		else if (s.service === "gel")
			// Kids gel is the one config-supported child discount: when this booking
			// is for a child and the chosen base is gel, price it at gelKid.
			items.push({
				label: s.isKid ? "Kids gel polish" : "Gel polish",
				amt: s.isKid ? CFG.gelKid : CFG.gelAdult,
			});
		else if (tiers.flat != null)
			// flat-priced service: repair, or any admin-created service (mirrors lib/pricing.js)
			items.push({ label: CFG.labels.service[s.service], amt: tiers.flat });
		if (s.finish && CFG.finish[s.finish])
			items.push({
				label: CFG.labels.finish[s.finish],
				amt: CFG.finish[s.finish],
			});
		if (s.design === "custom")
			// Menu-escape: customer is bringing inspo. Priced in studio, not now — `quote`
			// makes summary/checkout render "Quoted" instead of a misleading $0.
			items.push({ label: "Custom design", amt: 0, quote: true });
		else if (s.design && CFG.design[s.design])
			items.push({
				label: CFG.labels.design[s.design] + " design",
				amt: CFG.design[s.design],
			});
		// Art already bundled into the chosen design tier is not re-charged as an add-on.
		const includedByDesign = (CFG.content?.includedByDesign || {})[s.design] || [];
		s.addons.forEach((a) => {
			if (includedByDesign.includes(a)) return;
			items.push({ label: CFG.labels.addon[a], amt: CFG.addon[a] });
		});
	}
	if (s.who === "toes" || s.who === "both") {
		if (s.tbase) {
			// Merge base + finish into a single, natural-reading chip:
			//   acrylic + solid   → "Acrylic toes"
			//   acrylic + french  → "French acrylic toes"
			//   gel     + french  → "French gel toes"
			const baseWord = s.tbase === "acrylic" ? "acrylic" : "gel";
			const useFrench = s.tfinish === "french";
			const label = useFrench
				? `French ${baseWord} toes`
				: `${baseWord[0].toUpperCase()}${baseWord.slice(1)} toes`;
			items.push({
				label,
				amt: CFG.toesBase[s.tbase] + (useFrench ? CFG.toesFin.french : 0),
			});
		} else if (s.tfinish && CFG.toesFin[s.tfinish]) {
			// Finish picked without a base (shouldn't normally happen, but stay safe)
			items.push({
				label: s.tfinish === "french" ? "French tip toes" : "Solid toes",
				amt: CFG.toesFin[s.tfinish],
			});
		}
		s.taddons.forEach((a) =>
			items.push({ label: CFG.labels.toesAddon[a], amt: CFG.toesAdd[a] }),
		);
	}
	return items;
}

function total(s) {
	return priceItems(s).reduce((t, i) => t + i.amt, 0);
}

/* ---------- Duration (mirrors priceItems, sums CFG.dur in minutes; buffer-excluded) ---------- */
function durationItems(s) {
	const d = (CFG && CFG.dur) || {};
	const num = (v) => Number(v) || 0;
	const items = [];
	if (s.visit === "subscription") {
		if (s.plan) items.push(num((d.plan || {})[s.plan]));
		s.subaddons.forEach((a) => items.push(num((d.subAdd || {})[a])));
		return items;
	}
	if (s.who === "nails" || s.who === "both") {
		if ((s.service === "fullset" || s.service === "fillin") && s.length) {
			items.push(num((d.base || {})[s.service]));
			items.push(num((d.length || {})[s.length]));
		} else if (s.service === "overlay") items.push(num(d.overlay));
		else if (s.service === "gel") items.push(num(s.isKid ? d.gelKid : d.gelAdult));
		else if (s.service) {
			items.push(num((d.base || {})[s.service]));
			if (s.length) items.push(num((d.length || {})[s.length]));
		}
		if (s.finish) items.push(num((d.finish || {})[s.finish]));
		if (s.design) items.push(num((d.design || {})[s.design]));
		// Art bundled into the chosen design tier adds no extra duration (mirrors priceItems).
		const includedByDesign = (CFG.content?.includedByDesign || {})[s.design] || [];
		s.addons.forEach((a) => {
			if (includedByDesign.includes(a)) return;
			items.push(num((d.addon || {})[a]));
		});
	}
	if (s.who === "toes" || s.who === "both") {
		if (s.tbase) {
			items.push(num((d.toesBase || {})[s.tbase]));
			if (s.tfinish === "french") items.push(num((d.toesFin || {}).french));
		} else if (s.tfinish) items.push(num((d.toesFin || {})[s.tfinish]));
		s.taddons.forEach((a) => items.push(num((d.toesAdd || {})[a])));
	}
	return items;
}

function totalDuration(s) {
	return durationItems(s).reduce((t, m) => t + m, 0);
}

/* ---------- Deep links ---------- */
// ?step=<key> renders any screen directly: the preset stands in for the choices a
// customer would have made to get there, so every page is independently viewable
// (design iteration, automated edge-case tests). Every OTHER query param overrides a
// state key — values parse as JSON, falling back to the raw string. docs/DEEP-LINKS.md.
const PREVIEW_DATE = (() => { const d = new Date(); d.setDate(d.getDate() + 7); return [d.getFullYear(), String(d.getMonth() + 1).padStart(2, "0"), String(d.getDate()).padStart(2, "0")].join("-"); })();
const PREVIEW_NAILS = { visit: "appointment", who: "nails", service: "fullset", length: "medium", finish: "french", design: "basic", addons: ["chrome"] };
const STEP_PRESETS = {
	visit: {},
	who: { visit: "appointment" },
	base: { visit: "appointment", who: "nails" },
	length: { visit: "appointment", who: "nails", service: "fullset" },
	design: { visit: "appointment", who: "nails", service: "fullset", length: "medium" },
	tbase: { visit: "appointment", who: "toes" },
	taddons: { visit: "appointment", who: "toes", tbase: "acrylic", tfinish: "solid" },
	plan: { visit: "subscription", subMode: "new" },
	subaddons: { visit: "subscription", subMode: "new", plan: "medium" },
	summary: PREVIEW_NAILS,
	calendar: PREVIEW_NAILS,
	checkout: { ...PREVIEW_NAILS, date: PREVIEW_DATE, time: "13:00" },
	subcode: { visit: "subscription", subMode: "existing" },
	subdone: { visit: "subscription", subMode: "existing", subCode: "PREVIEW", subResult: { confirmation: "LLPREVIEW0000", remaining: 2 } },
};
function initialState() {
	const q = new URLSearchParams(location.search);
	const step = q.get("step"), s = freshState();
	if (!step || !(step in STEP_PRESETS)) return s; // no/unknown step → normal intro
	Object.assign(s, STEP_PRESETS[step], { step });
	for (const [k, v] of q)
		if (k !== "step" && Object.hasOwn(s, k)) { try { s[k] = JSON.parse(v); } catch { s[k] = v; } }
	const flow = computeFlow(s), i = flow.indexOf(step);
	s.history = ["intro", ...(i > -1 ? flow.slice(0, i + 1) : [step])]; // Back works as if clicked through
	return s;
}

/* ============== App ============== */
function App() {
	const [s, setS] = useState(initialState);
	const [cfg, setCfg] = useState(null);
	const [cfgError, setCfgError] = useState("");
	// Square public config lives OUTSIDE `s` so "start over" (setS(freshState())) never wipes it,
	// and so its async arrival re-renders. { configured, locationId }.
	const [sq, setSq] = useState({
		configured: false,
		locationId: null,
	});
	const [paying, setPaying] = useState(false); // checkout link in flight (consent modal busy state)
	const [payError, setPayError] = useState(""); // surfaced on the consent modal; blocks advancing

	const mounted = useRef(true);
	// Load site config with retry-and-backoff so a single cold-start / Neon-autosuspend
	// blip self-heals instead of stranding the user on an error. Reused by click-to-retry.
	const loadCfg = useCallback(async () => {
		if (mounted.current) setCfgError("");
		const delays = [400, 900, 1800, 3500]; // backoff between attempts; one final try after the last
		for (let attempt = 0; ; attempt++) {
			try {
				const r = await fetch("/api/site-config", { cache: "no-store" });
				if (!r.ok) throw new Error("site config request failed");
				const d = await r.json();
				if (!d || !d.config) throw new Error("site config missing");
				if (mounted.current) {
					CFG = d.config;
					setCfg(d.config);
					setCfgError("");
				}
				return;
			} catch (e) {
				if (!mounted.current) return;
				if (attempt >= delays.length) {
					setCfgError("Couldn't reach the server. Tap to try again.");
					return;
				}
				await new Promise((res) => setTimeout(res, delays[attempt]));
			}
		}
	}, []);

	useEffect(() => {
		mounted.current = true;
		loadCfg();
		fetch("/api/config")
			.then((r) => r.json())
			.then((d) => {
				if (mounted.current && d && d.square) setSq(d.square);
			})
			.catch(() => {}); // unconfigured/offline -> keep disabled checkout defaults
		return () => {
			mounted.current = false;
		};
	}, [loadCfg]);

	const flow = useMemo(() => computeFlow(s), [s.visit, s.isKid, s.who, s.subMode]);
	const stepIdx = flow.indexOf(s.step);

	const go = useCallback((nextStep) => {
		setS((prev) => {
			if (nextStep === prev.step) return prev;
			return { ...prev, step: nextStep, history: [...prev.history, nextStep] };
		});
	}, []);

	const back = useCallback(() => {
		setS((prev) => {
			const h = [...prev.history];
			if (h.length <= 1) return prev;
			h.pop();
			return { ...prev, step: h[h.length - 1], history: h };
		});
	}, []);

	const set = useCallback(
		(patch) => setS((prev) => ({ ...prev, ...patch })),
		[],
	);

	const toggleArr = useCallback((field, val) => {
		setS((prev) => {
			const arr = prev[field];
			const i = arr.indexOf(val);
			const next =
				i > -1 ? [...arr.slice(0, i), ...arr.slice(i + 1)] : [...arr, val];
			return { ...prev, [field]: next };
		});
	}, []);

	// navigate forward from current step
	const next = useCallback(() => {
		const cur = s.step;
		if (cur === "intro") return go("visit");
		if (cur === "visit") {
			if (s.visit === "subscription")
				return go(s.subMode === "existing" ? "subcode" : "plan");
			return go("who");
		}
		if (cur === "subcode") return go("calendar");
		if (cur === "plan") return go("subaddons");
		if (cur === "subaddons") return go("summary");
		if (cur === "who") {
			if (s.who === "toes") return go("tbase");
			return go("base");
		}
		if (cur === "base") {
			// repair is nail health, not a look — it skips length AND the look screen
			// (decisions.md F21 taxonomy; promoted to its own category 2026-06-10)
			if (s.service === "repair") return go(s.who === "both" ? "tbase" : "summary");
			const tiers = CFG.base[s.service];
			if (tiers && tiers.flat == null) return go("length"); // tiered base pricing → pick a length
			return go("design");
		}
		if (cur === "length") return go("design");
		if (cur === "design") {
			if (s.who === "both") return go("tbase");
			return go("summary");
		}
		if (cur === "tbase") return go("taddons");
		if (cur === "taddons") return go("summary");
		if (cur === "summary") return go("calendar");
		if (cur === "calendar") {
			// Existing-subscription redemption is prepaid: skip checkout/consent and book the
			// visit directly against the code (handled in App via redeemVisit).
			if (s.visit === "subscription" && s.subMode === "existing") return;
			return go("checkout");
		}
		if (cur === "checkout") {
			// Open the disclosure + consent modal; Square redirect starts on confirm.
			return set({ confirmOpen: true, acknowledged: false });
		}
	}, [s, go, set]);

	// Acknowledgement accepted -> create a Square-hosted Checkout link and leave card entry to Square.
	const completePayment = useCallback(async () => {
		const confirmation = newConfirmation();
		// Only server-supplied error strings reach the customer; raw fetch/parse exceptions
		// ("Failed to fetch", SyntaxError) fall back to this customer-grade sentence.
		// SQUARE BYPASS — restore the static Square fallback string when removing (docs/REMOVING-SQUARE-BYPASS.md)
		const genericMsg =
			sq && sq.bypass
				? "We couldn't confirm your booking — please try again."
				: "We couldn't open Square. Please try again.";
		setPayError("");
		setPaying(true);
		try {
			const geo = await getBrowserGeo();
			const booking = appointmentReportData(s);
			const form = new FormData();
			form.append("confirmation", confirmation);
			form.append("booking", JSON.stringify(booking));
			form.append(
				"acknowledgement",
				JSON.stringify({
					text: ACK_TEXT,
					version: ACK_VERSION,
					accepted: true,
				}),
			);
			form.append(
				"location",
				JSON.stringify({ browser: geo.browser, denied: geo.denied }),
			);
			(s.shareImages || []).forEach((file) => {
				form.append("shareImages", file, file.name || "inspo-image.jpg");
			});
			const resp = await fetch("/api/checkout/payment-link", {
				method: "POST",
				body: form,
			});
			const d = await resp.json();
			if (!resp.ok || !d.ok) {
				const err = new Error((d && d.error) || genericMsg);
				err.fromServer = true;
				throw err;
			}
			setS((prev) => ({
				...prev,
				confirmation,
				square: d.square || null,
				geo,
			}));
			window.location.assign(d.url);
		} catch (e) {
			setPaying(false);
			setPayError(e.fromServer ? e.message : genericMsg);
		}
	}, [s, sq]);

	// Existing-subscription prepaid visit: book directly against the code. No Square charge,
	// no consent modal (the plan was paid in full at purchase). Reuses paying/payError UI.
	const redeemVisit = useCallback(async () => {
		setPayError("");
		setPaying(true);
		try {
			const resp = await fetch("/api/subscriptions/redeem", {
				method: "POST",
				headers: { "Content-Type": "application/json" },
				body: JSON.stringify({ code: s.subCode, date: s.date, time: s.time }),
			});
			const d = await resp.json();
			if (!resp.ok || !d.ok)
				throw new Error((d && d.error) || "We couldn't book that visit.");
			setS((prev) => ({
				...prev,
				confirmation: d.confirmation,
				subResult: { confirmation: d.confirmation, remaining: d.remaining },
				step: "subdone",
				history: [...prev.history, "subdone"],
			}));
		} catch (e) {
			setPayError(e.message || "We couldn't book that visit. Please try again.");
		} finally {
			setPaying(false);
		}
	}, [s.subCode, s.date, s.time]);

	const reset = useCallback(() => setS(freshState()), []);

	// step indicator dots — show flow length minus intro
	const pips = useMemo(() => {
		const hidden = new Set(["visit", "subcode", "subdone"]);
		const visible = flow.filter((x) => !hidden.has(x) && !TAIL.includes(x));
		return visible.map((name) => {
			const idx = flow.indexOf(name);
			const cur = flow.indexOf(s.step);
			return idx < cur ? "done" : idx === cur ? "cur" : "todo";
		});
	}, [flow, s.step]);

	const showTopBar = s.step !== "intro";
	// subcode renders its own action (lookup gates advancing); subdone is a terminal success.
	const showBotBar =
		s.step !== "intro" && s.step !== "subcode" && s.step !== "subdone";

	const tailLabel =
		s.step === "summary"
			? "Review"
			: s.step === "calendar"
				? "Pick a time"
				: s.step === "checkout"
					? "Checkout"
					: s.step === "subcode"
						? "Your subscription"
						: s.step === "subdone"
							? "Booked"
							: null;

	if (!cfg) {
		return (
			<div
				className="screen"
				style={{ display: "grid", placeItems: "center", minHeight: "100dvh" }}
			>
				<div style={{ textAlign: "center", fontSize: "2em" }}>
					{/* unscoped topbar .mark classes = Playfair wordmark with no .hero subtree */}
					<h1 className="mark" style={{ fontSize: "1em" }}>
						<span className="roman">Lala</span>
						<span className="italic">luxe</span>
					</h1>
					<p
						onClick={cfgError ? () => loadCfg() : undefined}
						style={{
							fontFamily: "var(--italic)",
							fontStyle: "italic",
							color: "var(--tagline-ink)",
							fontSize: "0.45em",
							marginTop: "0.6em",
							cursor: cfgError ? "pointer" : "default",
						}}
					>
						{cfgError || "Loading..."}
					</p>
				</div>
			</div>
		);
	}

	return (
		<>
			{showTopBar && (
				<div className="topbar">
					<span className="mark">
						<span className="roman">Lala</span>
						<span className="italic">luxe</span>
					</span>
					<span
						className="steps"
						{...(!tailLabel
							? {
									role: "img",
									"aria-label":
										pips.indexOf("cur") < 0
											? `${pips.length} steps ahead`
											: `Step ${pips.indexOf("cur") + 1} of ${pips.length}`,
								}
							: {})}
					>
						{tailLabel ? (
							<span className="all-done">{tailLabel}</span>
						) : (
							pips.map((p, i) => (
								<span
									key={i}
									aria-hidden="true"
									className={
										"pip " + (p === "done" ? "done" : p === "cur" ? "cur" : "")
									}
								/>
							))
						)}
					</span>
					<span className="topbar-right">
						<ThemeToggle />
					</span>
				</div>
			)}

			<div className="screen-wrap">
				{renderScreen(s, cfg, set, toggleArr, next, go, back, reset, sq)}
			</div>

			{showTopBar && !TAIL.includes(s.step) && <PicksTray s={s} cfg={cfg} />}

			{showBotBar &&
				s.step === "calendar" &&
				s.subMode === "existing" &&
				payError && <div className="sub-error" role="alert">{payError}</div>}

			{showBotBar && (
				<div className="botbar">
					{s.history.length > 1 ? (
						<button className="back-link" onClick={back}>
							Back
						</button>
					) : (
						<span style={{ flex: 1 }} />
					)}
					{s.step === "calendar" &&
					s.visit === "subscription" &&
					s.subMode === "existing" ? (
						<button
							className="cta cherry"
							disabled={!(s.date && s.time) || paying}
							onClick={redeemVisit}
						>
							{paying ? "Booking…" : "Confirm visit"}{" "}
							<span className="arrow"></span>
						</button>
					) : (
						<ContinueButton s={s} sq={sq} onNext={next} />
					)}
				</div>
			)}

			{s.confirmOpen && (
				<ConsentModal
					s={s}
					sq={sq} // SQUARE BYPASS — gates the modal's payment copy (docs/REMOVING-SQUARE-BYPASS.md)
					busy={paying}
					error={payError}
					onToggle={() => set({ acknowledged: !s.acknowledged })}
					onCancel={() => {
						if (!paying) {
							setPayError("");
							set({ confirmOpen: false });
						}
					}}
					onConfirm={completePayment}
				/>
			)}
		</>
	);
}

function renderScreen(s, cfg, set, toggleArr, next, go, back, reset, sq) {
	switch (s.step) {
		case "intro":
			return <Intro cfg={cfg} onStart={() => go("visit")} />;
		case "visit":
			return (
				<VisitStep
					cfg={cfg}
					s={s}
					set={set}
					sq={sq} // SQUARE BYPASS — gates the subscription tile/fork copy (docs/REMOVING-SQUARE-BYPASS.md)
					onPick={(v) => {
						// Appointment auto-advances. Subscription stays on this screen to choose
						// NEW vs. existing (a prepaid plan already in hand) before advancing.
						if (v === "subscription") {
							set({ visit: v, subMode: null });
							return;
						}
						set({ visit: v, subMode: null });
						setTimeout(() => go("who"), 220);
					}}
					onSubMode={(mode) => {
						set({ visit: "subscription", subMode: mode });
						setTimeout(() => go(mode === "existing" ? "subcode" : "plan"), 220);
					}}
				/>
			);
		case "who":
			return <WhoStep cfg={cfg} s={s} set={set} />;
		case "base":
			return <BaseStep cfg={cfg} s={s} set={set} toggleArr={toggleArr} />;
		case "length":
			return <LengthStep cfg={cfg} s={s} set={set} />;
		case "design":
			return <DesignStep cfg={cfg} s={s} set={set} toggleArr={toggleArr} />;
		case "tbase":
			return <TBaseStep cfg={cfg} s={s} set={set} />;
		case "taddons":
			return <TAddonsStep cfg={cfg} s={s} toggleArr={toggleArr} />;
		case "plan":
			return <PlanStep cfg={cfg} s={s} set={set} sq={sq} />; // sq: SQUARE BYPASS — gates the h-sub money clause (docs/REMOVING-SQUARE-BYPASS.md)
		case "subaddons":
			return <SubAddonsStep cfg={cfg} s={s} toggleArr={toggleArr} />;
		case "summary":
			return <SummaryStep cfg={cfg} s={s} sq={sq} />; // sq: SQUARE BYPASS — gates the receipt's due copy (docs/REMOVING-SQUARE-BYPASS.md)
		case "calendar":
			return <CalendarStep cfg={cfg} s={s} set={set} sq={sq} dur={totalDuration(s)} />;
		case "checkout":
			return <CheckoutStep cfg={cfg} s={s} set={set} sq={sq} />;
		case "subcode":
			return <SubCodeStep cfg={cfg} s={s} set={set} go={go} />;
		case "subdone":
			return <SubDoneStep cfg={cfg} s={s} reset={reset} />;
		default:
			return null;
	}
}

/* ---------- Intro ---------- */
// Config is the ONLY gallery source (no in-code fallback — see CLAUDE.md).
// RecentWork null-renders on an empty list, so the hero degrades gracefully.
function getGallery(cfg) {
	const gallery = cfg?.content?.gallery;
	return Array.isArray(gallery) ? gallery : [];
}

const MONTHS_LONG = [
	"January",
	"February",
	"March",
	"April",
	"May",
	"June",
	"July",
	"August",
	"September",
	"October",
	"November",
	"December",
];
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const DOW_SHORT = ["S", "M", "T", "W", "T", "F", "S"];

function ymd(y, m, d) {
	return (
		y + "-" + String(m + 1).padStart(2, "0") + "-" + String(d).padStart(2, "0")
	);
}
function parseYmd(k) {
	const [y, m, d] = k.split("-").map(Number);
	return new Date(y, m - 1, d);
}
function sameDay(a, b) {
	return (
		a.getFullYear() === b.getFullYear() &&
		a.getMonth() === b.getMonth() &&
		a.getDate() === b.getDate()
	);
}
function prettyDate(k) {
	const d = parseYmd(k);
	return d.toLocaleDateString(undefined, {
		weekday: "long",
		month: "long",
		day: "numeric",
	});
}
function prettyDateShort(k) {
	const d = parseYmd(k);
	return d.toLocaleDateString(undefined, {
		weekday: "short",
		month: "short",
		day: "numeric",
	});
}
function fmtTime(t) {
	// t = "HH:MM" 24h
	const [hh, mm] = t.split(":").map(Number);
	const ap = hh >= 12 ? "pm" : "am";
	const h12 = ((hh + 11) % 12) + 1;
	return h12 + ":" + String(mm).padStart(2, "0") + " " + ap;
}

const ALL_SLOTS = ["10:00", "11:30", "13:00", "14:30", "16:00", "17:30"];
// Fallback when /api/availability is empty for the whole range or the fetch fails: treat
// non-past Tue-Sat as fully open so the flow can reach checkout locally (no DB seeded).
// Deterministic "all open", not the old pseudo-random. Closed Sun/Mon, no past days.
function fallbackSlots(key) {
	const dow = parseYmd(key).getDay();
	return dow === 0 || dow === 1 ? [] : ALL_SLOTS;
}

function formatPhone(v) {
	// Normalize a leading US country code so "+1 901 555 0123" (the standard autofill
	// format) masks to (901) 555-0123 instead of a corrupted 10-digit slice.
	let d = v.replace(/\D/g, "");
	if (d.length > 10 && d[0] === "1") d = d.slice(1);
	d = d.slice(0, 10);
	if (d.length < 4) return d;
	if (d.length < 7) return "(" + d.slice(0, 3) + ") " + d.slice(3);
	return "(" + d.slice(0, 3) + ") " + d.slice(3, 6) + "-" + d.slice(6);
}

ReactDOM.createRoot(document.getElementById("app")).render(<App />);

