<script lang="ts">
	import { onMount } from 'svelte';
	import { toast } from 'svelte-sonner';
	import { extractErrorMessage } from '$lib/utils/toast-safe';
	import * as Dialog from '$lib/components/ui/dialog';
	import { Button } from '$lib/components/ui/button';
	import { Input } from '$lib/components/ui/input';
	import { Checkbox } from '$lib/components/ui/checkbox';
	import type { Booking, BookingService, BookingDiscount } from '$lib/types/booking';
	import { apiFetch } from '$lib/utils/api';
	import { formatCurrency } from '$lib/utils/format';
	import {
		buildCashTillPaymentBody,
		CARD_VERIFICATION_RETRY_MESSAGE,
		campaignDiscountPence,
		cashChargeBasePence,
		isOverflowTipConfirmationRequired,
		isVerificationRequiredSignal,
		PAYMENT_METHOD_SAVED_CARD,
		runSavedCardSCAProactively,
		sanitizeDecimalInput,
		shouldShowSCARefusal,
		submitPaymentWithRetry,
		VERIFICATION_REQUIRED_MESSAGE
	} from '$lib/square/square';
	import ScaFallbackConsentDialog from '$lib/components/payments/ScaFallbackConsentDialog.svelte';
	import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
	import { generateUUID } from '$lib/utils/uuid';
	import { POLICY } from '$lib/constants/policy';

	interface Props {
		booking: Booking;
		onClose: () => void;
		onComplete: (payment: PaymentResult) => void;
	}

	const { booking, onClose, onComplete }: Props = $props();

	type PaymentStatus =
		| 'idle'
		| 'selecting'
		| 'card-processing'
		| 'card-polling'
		| 'cash-entering'
		| 'cash-confirming'
		| 'gift-entering'
		| 'gift-confirming'
		| 'saved-card-selecting'
		| 'saved-card-processing'
		| 'saved-card-waiting-sca'
		| 'success'
		| 'error';

	type PaymentResult = {
		checkout_id: string;
		status: string;
		card_brand?: string;
		last4?: string;
		amount: number;
	};

	type PaymentMethod = 'card' | 'cash' | 'giftcard' | typeof PAYMENT_METHOD_SAVED_CARD | null;

	let status = $state<PaymentStatus>('idle');
	let selectedMethod = $state<PaymentMethod>(null);
	let checkoutId = $state<string | null>(null);
	let paymentResult = $state<PaymentResult | null>(null);
	let error = $state<string | null>(null);
	// Synchronous double-click guard. Svelte 5 reactivity is async (effects run
	// on the next microtask), so the reactive `status` may not propagate to the
	// button's `disabled` binding before a fast second click fires. This non-
	// reactive flag is checked synchronously at the start of every handler.
	let isProcessingPaymentSync = false;

	// Overpayment confirmation (mirrors UserPaymentModal/BookingFlow). The
	// backend rejects a payment that exceeds the booking's remaining balance
	// unless the request carries `confirm_overflow_tip: true` — a tip is
	// gratuity for service already rendered. The guard fires on STALE booking
	// data (multi-tab, admin-changed totals) where the operator would otherwise
	// be stuck with an unresolvable 400; the rejected request body is parked
	// here and a Confirm/Cancel prompt is shown, with Confirm resending the SAME
	// body plus the flag.
	let overflowConfirm = $state<{
		amountPence: number;
		overflowPence: number;
		body: Record<string, unknown>;
	} | null>(null);

	function confirmOverflowPayment() {
		const pending = overflowConfirm;
		if (!pending || status === 'saved-card-processing') return;
		status = 'saved-card-processing';
		error = null;
		isProcessingPaymentSync = true;
		apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({ ...pending.body, confirm_overflow_tip: true })
		})
			.then(async (response) => {
				if (!response.ok) {
					const errData = await response.text();
					throw new Error(extractErrorMessage(errData) || 'Failed to process payment');
				}
				const data = await response.json();
				status = 'success';
				paymentResult = {
					checkout_id: data.payment_id || data.checkout_id || data.id || '',
					status: 'COMPLETED',
					card_brand: data.card_brand,
					last4: data.card_last4,
					amount: data.amount
				};
				overflowConfirm = null;
				toast.success('Payment successful');
				onComplete(paymentResult);
			})
			.catch((_err) => {
				status = 'error';
				error = _err instanceof Error ? _err.message : 'Failed to process payment';
				toast.error(error ?? 'Unknown error');
			})
			.finally(() => {
				isProcessingPaymentSync = false;
			});
	}

	function cancelOverflowConfirmation() {
		overflowConfirm = null;
		status = 'idle';
		selectedMethod = null;
	}

	// Outcome of the last saved-card SCA attempt: 'sca-unavailable' drives the
	// C6 refusal notice (SCA is the ONLY authorisation — there is no 2FA
	// fallback); every other outcome keeps SCA primary for the next retry.
	let lastSCAOutcome = $state('');

	const stamps = $derived(booking.user?.loyalty_stamps ?? 0);
	let useLoyalty = $state(false);

	// B3: pence already paid against this booking. The AppointmentInfo handed in
	// by /api/admin/today/current-next carries no amount_paid/amount_due/
	// payments, so this is fetched fresh from the admin booking detail endpoint
	// on mount and subtracted from the charge (see netTotal).
	//
	// Money finding 1: `amount_paid` (summed over ALL completed payments) can
	// include tips — a tip is gratuity, not booking credit, so it must not
	// reduce what the customer still owes. The booking detail endpoint computes
	// amount_paid in Go over every completed payment (no payment_type filter),
	// so the tip-excluded obligation is derived here from the payments list
	// rather than trusting amount_paid. This stays consistent whether or not
	// the backend starts excluding tips from amount_paid (idempotent either
	// way). The tip-INCLUSIVE amount_paid is kept for the "Already paid"
	// display (mirrors the customer modal's "Amount Paid" row).
	let amountPaidPence = $state(0);
	let tipExcludedPaidPence = $state(0);
	async function fetchAmountPaid() {
		try {
			const resp = await apiFetch(`/api/admin/bookings/${booking.id}`);
			if (resp.ok) {
				const data = await resp.json();
				if (typeof data.amount_paid === 'number') {
					amountPaidPence = Math.round(data.amount_paid * 100);
				}
				tipExcludedPaidPence = Math.round(
					(data.payments ?? [])
						.filter(
							(p: { status: string; payment_type: string; payment_method?: string }) =>
								p.status === 'completed' && p.payment_type !== 'tip' && p.payment_method !== 'discount'
						)
						.reduce((sum: number, p: { amount: number }) => sum + (p.amount || 0), 0) * 100
				);
				return;
			}
		} catch (_err) {
			// fall through to the booking prop below
		}
		amountPaidPence = Math.round((booking.amount_paid ?? 0) * 100);
		tipExcludedPaidPence = amountPaidPence;
	}

	const loyaltyEligible = $derived(
		stamps >= 10 &&
			!(booking.discounts ?? []).some((d: BookingDiscount) => d.discount_source === 'loyalty') &&
			booking.total_amount > 0 &&
			tipExcludedPaidPence === 0
	);

	const loyaltyDiscount = $derived(
		useLoyalty ? Math.round(booking.total_amount * 100 * POLICY.LOYALTY_DISCOUNT_RATE) : 0
	);

	// Campaign discount preview — fetched on mount, mirroring the customer flow
	// (UserPaymentModal). The backend AUTO-APPLIES eligible campaigns at payment
	// /completion, so the admin modal must show and charge the DISCOUNTED amount:
	// charging the pre-campaign total would over-credit the ledger (the backend
	// records the full payment AND the discount rows). `netTotal` therefore
	// subtracts these pence, and every charge handler derives from it.
	let discountPreview = $state<{
		eligible: boolean;
		discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
		original_total: number;
		discounted_total: number;
	} | null>(null);

	let customerBalance = $state(0);
	let giftCardPaymentAmount = $state('');
	async function fetchCustomerGiftCardBalance() {
		const targetUserId = booking.user_id ?? booking.user?.id;
		if (!targetUserId) return;
		try {
			const res = await apiFetch(`/api/admin/users/${targetUserId}/giftcard-balance`);
			if (res.ok) {
				const data = await res.json();
				customerBalance = data.balance;
				giftCardPaymentAmount = Math.min(data.balance, totalDue).toFixed(2);
				if (data.balance > 0) {
					useAccountBalance = true;
				}
			}
		} catch {
			// ignore
		}
	}

	type ServiceOverride = {
		price: string;
		originalPrice: number;
	};

	let serviceOverrides = $state<Record<string, ServiceOverride>>({});

	$effect(() => {
		const uid = booking.user_id ?? booking.user?.id;
		if (uid) {
			fetchCustomerGiftCardBalance();
			fetchSavedCards();
		}
		const services = booking.services ?? [];
		const overrides: Record<string, ServiceOverride> = {};
		for (const s of services) {
			const price = s.override_price ?? s.price ?? 0;
			overrides[s.service_id] = {
				price: price.toFixed(2),
				originalPrice: s.price ?? 0
			};
		}
		serviceOverrides = overrides;
	});

	// Single shared sanitizer for all decimal money inputs: strips non-numeric
	// characters and keeps only the first decimal point (so "1.2.3" → "1.23").
	// Defined once in square.ts and imported here so the payment surfaces can't
	// drift.

	function handlePriceInput(serviceId: string, value: string) {
		const sanitized = sanitizeDecimalInput(value);
		if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
			serviceOverrides = {
				...serviceOverrides,
				[serviceId]: {
					...serviceOverrides[serviceId],
					price: sanitized
				}
			};
		}
	}

	let tipEnabled = $state(false);
	let selectedTipPercent = $state<number | null>(null);
	let customTipAmount = $state<string>('');

	// Partial payment amounts for card and saved card (in pounds, user enters)
	let cardPaymentAmount = $state<string>('');
	let lastValidCardAmount = $state<string>('');
	let savedCardPaymentAmount = $state<string>('');
	let lastValidSavedCardAmount = $state<string>('');
	let showPartialCharge = $state(false);

	let pollingInterval: ReturnType<typeof setInterval> | null = null;

	function selectTipPercent(percent: number) {
		selectedTipPercent = percent;
		customTipAmount = '';
		tipEnabled = true;
	}

	function handleCustomTipInput(e: Event) {
		const input = e.target as HTMLInputElement;
		const sanitized = sanitizeDecimalInput(input.value);
		if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
			customTipAmount = sanitized;
		}
		selectedTipPercent = null;
		tipEnabled = customTipAmount !== '' && parseFloat(customTipAmount) > 0;
	}

	function handleCardAmountInput(e: Event) {
		const input = e.target as HTMLInputElement;
		const sanitized = sanitizeDecimalInput(input.value);
		if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
			cardPaymentAmount = sanitized;
			lastValidCardAmount = sanitized;
		} else {
			cardPaymentAmount = lastValidCardAmount;
			input.value = lastValidCardAmount;
		}
	}

	function handleSavedCardAmountInput(e: Event) {
		const input = e.target as HTMLInputElement;
		const sanitized = sanitizeDecimalInput(input.value);
		if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
			savedCardPaymentAmount = sanitized;
			lastValidSavedCardAmount = sanitized;
		} else {
			savedCardPaymentAmount = lastValidSavedCardAmount;
			input.value = lastValidSavedCardAmount;
		}
	}

	function getServicePrice(service: BookingService): number {
		const override = serviceOverrides[service.service_id];
		if (override && override.price !== '') {
			const parsed = parseFloat(override.price);
			if (!isNaN(parsed) && parsed > 0) return parsed;
		}
		return service.override_price ?? service.price ?? 0;
	}

	const subtotal = $derived(
		(booking.services ?? []).reduce((sum, s) => sum + getServicePrice(s), 0)
	);
	const discountSum = $derived(
		(booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)
	);
	// Campaign discounts apply automatically at payment/completion server-side,
	// so the charge must be the subtotal minus already-applied discounts minus
	// the eligible campaign credit — otherwise the customer is overcharged.
	//
	// B3: prior payments are also subtracted. The booking object handed to this
	// modal (from /api/admin/today/current-next, AppointmentInfo) carries no
	// amount_paid/amount_due/payments, so on mount the modal fetches the
	// authoritative paid total from GET /api/admin/bookings/{id} (full Booking
	// shape, admin-accessible) and charges only the remaining obligation. The
	// backend money agent clamps the booking portion of a payment to the
	// remaining value, so the frontend charge and the backend record now agree
	// and a prior deposit can no longer land as an unintended tip.
	// netTotal: the obligation after subtracting discounts and prior real
	// payments, in pounds. subtotal and discountSum are already in pounds;
	// campaignDiscountPence (the campaign preview) and tipExcludedPaidPence
	// (sum of completed non-tip, non-discount payments) are in pence, so the
	// arithmetic is done in pence then converted back to pounds to avoid
	// mixing units.
	const netTotal = $derived(
		Math.max(
			0,
			( Math.round((subtotal - discountSum) * 100)
				- campaignDiscountPence(discountPreview)
				- tipExcludedPaidPence
			) / 100
		)
	);

	const tipPercentages = $derived.by(() => {
		if (netTotal <= 0) return [];
		return POLICY.TIP_PRESET_PCTS.map((pct) => ({
			pct,
			amount: Math.round(netTotal * (pct / 100) * 100) / 100
		}));
	});

	const tipMultiplier = $derived(
		selectedTipPercent !== null
			? 1 + selectedTipPercent / 100
			: customTipAmount && parseFloat(customTipAmount) > 0
				? 1 + parseFloat(customTipAmount) / netTotal
				: 1
	);

	const totalWithTip = $derived(tipEnabled ? netTotal * tipMultiplier : netTotal);
	const tipDisplay = $derived(
		selectedTipPercent !== null
			? `${selectedTipPercent}%`
			: customTipAmount && parseFloat(customTipAmount) > 0
				? `${formatCurrency(parseFloat(customTipAmount))}`
				: ''
	);

	const totalDue = $derived(tipEnabled ? totalWithTip : netTotal);

	// The amount added on top of the pre-tip total when a tip is selected.
	const tipDelta = $derived(tipEnabled ? totalWithTip - netTotal : 0);

	// True when there is genuinely nothing to charge — the booking is fully
	// covered by discounts (and no tip is being added). Payment entry is
	// disabled in that state; the handlers also guard defensively.
	const nothingToCharge = $derived(totalDue <= 0);

	// Card payment amount validation
	const cardAmountNum = $derived(cardPaymentAmount === '' ? totalDue : parseFloat(cardPaymentAmount));
	const cardAmountValid = $derived(
		cardPaymentAmount === '' ||
			(cardPaymentAmount !== '' &&
				!isNaN(cardAmountNum) &&
				cardAmountNum > 0 &&
				cardAmountNum <= totalDue &&
				/^\d+(\.\d{0,2})?$/.test(cardPaymentAmount))
	);
	const cardValidationError = $derived(
		cardPaymentAmount !== '' && !cardAmountValid
			? cardPaymentAmount === ''
				? ''
				: !/^\d+(\.\d{0,2})?$/.test(cardPaymentAmount)
					? 'Enter a valid amount (max 2 decimal places)'
					: cardAmountNum <= 0
						? 'Amount must be greater than 0'
						: cardAmountNum > totalDue
							? `Amount cannot exceed ${formatCurrency(totalDue)}`
							: ''
			: ''
	);

	// Saved card payment amount validation
	const savedCardAmountNum = $derived(
		savedCardPaymentAmount === '' ? totalDue : parseFloat(savedCardPaymentAmount)
	);
	const savedCardAmountValid = $derived(
		savedCardPaymentAmount === '' ||
			(savedCardPaymentAmount !== '' &&
				!isNaN(savedCardAmountNum) &&
				savedCardAmountNum > 0 &&
				savedCardAmountNum <= totalDue &&
				/^\d+(\.\d{0,2})?$/.test(savedCardPaymentAmount))
	);
	const savedCardValidationError = $derived(
		savedCardPaymentAmount !== '' && !savedCardAmountValid
			? savedCardPaymentAmount === ''
				? ''
				: !/^\d+(\.\d{0,2})?$/.test(savedCardPaymentAmount)
					? 'Enter a valid amount (max 2 decimal places)'
					: savedCardAmountNum <= 0
						? 'Amount must be greater than 0'
						: savedCardAmountNum > totalDue
							? `Amount cannot exceed ${formatCurrency(totalDue)}`
							: ''
			: ''
	);

	async function applyLoyaltyRedemption(): Promise<void> {
		if (!useLoyalty) return;
		const res = await apiFetch(`/api/admin/bookings/${booking.id}/apply-redemption`, {
			method: 'POST',
			headers: { 'Content-Type': 'application/json' }
		});
		if (!res.ok) {
			const errData = await res.text();
			throw new Error(extractErrorMessage(errData) || 'Failed to apply loyalty discount');
		}
	}

	async function saveServiceOverrides(): Promise<void> {
		// Check if any service has an override that differs from the original price
		const services = booking.services ?? [];
		const hasOverrides = services.some((s) => {
			const override = serviceOverrides[s.service_id];
			if (!override) return false;
			const overridePrice = parseFloat(override.price);
			return !isNaN(overridePrice) && Math.abs(overridePrice - override.originalPrice) > 0.01;
		});

		if (!hasOverrides) return;

		// Build the request payload
		const serviceIds = services.map((s) => s.service_id).filter((id): id is string => !!id);
		const serviceOverridesPayload = services
			.filter((s) => {
				const override = serviceOverrides[s.service_id];
				if (!override) return false;
				const overridePrice = parseFloat(override.price);
				return !isNaN(overridePrice) && Math.abs(overridePrice - override.originalPrice) > 0.01;
			})
			.map((s) => ({
				service_id: s.service_id,
				override_price: parseFloat(serviceOverrides[s.service_id].price)
			}));

		const res = await apiFetch(`/api/admin/bookings/${booking.id}/services`, {
			method: 'PUT',
			headers: { 'Content-Type': 'application/json' },
			body: JSON.stringify({
				service_ids: serviceIds,
				service_overrides: serviceOverridesPayload
			})
		});

		if (!res.ok) {
			const errData = await res.text();
			throw new Error(extractErrorMessage(errData) || 'Failed to save service overrides');
		}
	}

	async function handleCardPayment() {
		if (isProcessingPaymentSync) return;
		const finalAmount = cardAmountNum;

		if (isNaN(finalAmount) || finalAmount <= 0) {
			toast.error('Please enter a valid amount');
			return;
		}
		if (finalAmount > totalDue) {
			toast.error(`Amount cannot exceed ${formatCurrency(totalDue)}`);
			return;
		}
		// The loyalty redemption is applied on top of totalDue; guard against
		// the effective charge being zero or negative.
		if (Math.round(finalAmount * 100) - loyaltyDiscount <= 0) {
			toast.error('Nothing to charge — the booking is fully covered by discounts');
			return;
		}

		isProcessingPaymentSync = true;
		status = 'card-processing';
		error = null;

		try {
			await saveServiceOverrides();
			await applyLoyaltyRedemption();

			const response = await apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify({
					amount: Math.round(finalAmount * 100) - loyaltyDiscount,
					payment_type: 'full',
					tip_enabled: tipEnabled
				})
			});

			if (!response.ok) {
				const errData = await response.text();
				// Overflow guard (defensive — the admin terminal path currently
				// clamps instead, but a 400 carrying the code must surface the
				// Confirm/Cancel prompt like the customer modal, not a dead-end).
				if (isOverflowTipConfirmationRequired(errData)) {
					overflowConfirm = {
						amountPence: Math.round(finalAmount * 100) - loyaltyDiscount,
						overflowPence: Math.max(
							0,
							Math.round(finalAmount * 100) - loyaltyDiscount - Math.round(netTotal * 100)
						),
						body: {
							amount: Math.round(finalAmount * 100) - loyaltyDiscount,
							payment_type: 'full',
							tip_enabled: tipEnabled
						}
					};
					return;
				}
				throw new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
			}

			const data = await response.json();
			checkoutId = data.checkout_id;
			status = 'card-polling';
			startPolling();
		} catch (_err) {
			status = 'error';
			error = _err instanceof Error ? _err.message : 'Failed to initiate payment';
			toast.error(error ?? 'Unknown error');
		} finally {
			isProcessingPaymentSync = false;
		}
	}

	function startPolling() {
		if (!checkoutId) return;

		pollingInterval = setInterval(async () => {
			try {
				const response = await apiFetch(
					`/api/admin/payments/${checkoutId}/status?booking_id=${booking.id}`,
					{
						headers: { 'Content-Type': 'application/json' }
					}
				);

				if (!response.ok) {
					throw new Error('Failed to check payment status');
				}

				const data = await response.json();

				if (data.status === 'COMPLETED') {
					stopPolling();
					status = 'success';
					paymentResult = {
						checkout_id: checkoutId!,
						status: data.status,
						card_brand: data.card_brand,
						// The API serializes the last-four as card_last4 (see
						// PaymentStatusResponse); the success screen reads
						// paymentResult.last4, so keep the local field name.
						last4: data.card_last4,
						amount: data.amount
					};
					// Deliberately do NOT call onComplete() here: it would make
					// the parent close this modal instantly, so the green-tick
					// success state would never be seen. The modal stays open
					// showing the tick until the operator clicks Done, which
					// fires handleSuccessDone() → onComplete + onClose.
				} else if (data.status === 'FAILED') {
					stopPolling();
					status = 'error';
					error = data.error_message || 'Payment failed';
					toast.error(error as string);
				}
			} catch {
				stopPolling();
				status = 'error';
				error = 'Failed to check payment status';
				toast.error(error);
			}
		}, 2000);
	}

	function stopPolling() {
		if (pollingInterval) {
			clearInterval(pollingInterval);
			pollingInterval = null;
		}
	}

	function handleClose() {
		stopPolling();
		onClose();
	}

	// True while a charge (or the out-of-band SCA challenge the customer must
	// approve in their banking app) is in flight — ESC/overlay close must be
	// blocked then, because the charge may still land.
	function isChargeInFlight(status: PaymentStatus): boolean {
		return (
			status === 'card-processing' ||
			status === 'card-polling' ||
			status === 'cash-confirming' ||
			status === 'gift-confirming' ||
			status === 'saved-card-processing' ||
			status === 'saved-card-waiting-sca'
		);
	}

	// Called from the success state's Done button: notify the parent (so it can
	// refresh the booking/payment data) and then close the modal. Kept separate
	// from handleClose so a success state never closes without the callback.
	function handleSuccessDone() {
		stopPolling();
		if (paymentResult) {
			onComplete(paymentResult);
		}
		onClose();
	}

	function resetToSelect() {
		stopPolling();
		status = 'idle';
		selectedMethod = null;
		checkoutId = null;
		error = null;
		// Clear any refusal from a previous attempt so re-entering the saved-card
		// screen doesn't re-show it before a fresh SCA attempt.
		lastSCAOutcome = '';
	}

	$effect(() => {
		return () => {
			stopPolling();
		};
	});

	// Fetch the eligible campaign discount preview once on mount. Mirrors the
	// customer flow (UserPaymentModal) so the admin modal charges the same
	// discounted amount the backend will auto-apply.
	onMount(async () => {
		fetchAmountPaid();
		try {
			const resp = await apiFetch(`/api/bookings/${booking.id}/discount-preview`);
			if (resp.ok) {
				discountPreview = await resp.json();
			}
		} catch (_err) {
			console.error('Failed to fetch discount preview:', _err);
		}
	});

	let cashAmount = $state<string>('');
	const cashAmountNum = $derived(cashAmount === '' ? 0 : parseFloat(cashAmount));
	// Cash charge base in pence. The backend's CreateTerminalPayment derives the
	// tip from `amount − remaining`, and `remaining` (GetBookingRemainingBalancePence)
	// does NOT subtract the pending campaign discount — so the campaign preview
	// must be restored into the charge base or the tip is absorbed into booking
	// credit. Shared with the change/tip display so every cash figure agrees.
	const cashDuePence = $derived(
		cashChargeBasePence(
			Math.round(totalDue * 100),
			campaignDiscountPence(discountPreview),
			loyaltyDiscount
		)
	);
	const cashDue = $derived(cashDuePence / 100);
	const changeDue = $derived(cashAmountNum > cashDue ? cashAmountNum - cashDue : 0);
	let extraAsTip = $state(false);

	function handleCashInput(e: Event) {
		const input = e.target as HTMLInputElement;
		const sanitized = sanitizeDecimalInput(input.value);
		if (/^\d+(\.\d{0,2})?$/.test(sanitized) || sanitized === '') {
			cashAmount = sanitized;
		}
	}

	async function handleCashPayment() {
		if (isProcessingPaymentSync) return;

		if (cashDue <= 0) {
			toast.error('Nothing to charge — the booking is fully covered by discounts');
			return;
		}
		if (cashAmountNum < cashDue) {
			toast.error('Cash amount must cover the total');
			return;
		}

		const tipAmount = extraAsTip ? cashAmountNum - cashDue : 0;

		isProcessingPaymentSync = true;
		status = 'cash-confirming';
		error = null;

		try {
			await applyLoyaltyRedemption();

			// Cash till-sale body: the tip is FOLDED into the amount (the backend's
			// CreateTerminalPaymentRequest derives the tip from `amount - remaining`
			// when tip_enabled; it has no tip_amount field, so sending one would
			// silently drop the tip).
			const body = buildCashTillPaymentBody(Math.round(cashDue * 100), Math.round(tipAmount * 100));

			const response = await apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify(body)
			});

			if (!response.ok) {
				const errData = await response.text();
				throw new Error(extractErrorMessage(errData) || 'Failed to process payment');
			}

			const data = await response.json();
			status = 'success';
			paymentResult = {
				checkout_id: data.checkout_id || data.id || '',
				status: 'COMPLETED',
				amount: data.amount
			};
			toast.success('Cash payment recorded');
			onComplete(paymentResult);
		} catch (_err) {
			status = 'error';
			error = _err instanceof Error ? _err.message : 'Failed to process payment';
			toast.error(error ?? 'Unknown error');
		} finally {
			isProcessingPaymentSync = false;
		}
	}

	let giftCardId = $state('');
	let useAccountBalance = $state(false);

	function formatAndPreserveCursor(
		input: HTMLInputElement,
		formatter: (val: string) => string,
		charRegex: RegExp = /\d/
	): string {
		const rawValue = input.value;
		const oldSelectionStart = input.selectionStart || 0;

		let charsBeforeCursor = 0;
		for (let i = 0; i < oldSelectionStart; i++) {
			if (charRegex.test(rawValue[i])) {
				charsBeforeCursor++;
			}
		}

		const formatted = formatter(rawValue);
		input.value = formatted;

		let newSelectionStart = 0;
		let charsFound = 0;
		for (let i = 0; i < formatted.length; i++) {
			if (charsFound === charsBeforeCursor) {
				break;
			}
			if (charRegex.test(formatted[i])) {
				charsFound++;
			}
			newSelectionStart++;
		}

		requestAnimationFrame(() => {
			input.setSelectionRange(newSelectionStart, newSelectionStart);
		});

		return formatted;
	}

	function formatGiftCardId(value: string): string {
		let raw = value.replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
		if (raw.length > 12) raw = raw.slice(0, 12);
		let formatted = '';
		if (raw.length > 0) formatted += raw.slice(0, 4);
		if (raw.length > 4) formatted += '-' + raw.slice(4, 8);
		if (raw.length > 8) formatted += '-' + raw.slice(8, 12);
		return formatted.toUpperCase();
	}

	function handleGiftCardInput(e: Event) {
		const input = e.target as HTMLInputElement;
		const formatted = formatAndPreserveCursor(input, formatGiftCardId, /[a-zA-Z0-9]/);
		giftCardId = formatted;
	}

	const giftCardValid = $derived(useAccountBalance || giftCardId.replace(/-/g, '').length === 12);

	async function handleGiftCardPayment() {
		if (isProcessingPaymentSync) return;
		if (!giftCardValid) {
			toast.error('Please enter a valid 12-character gift card code');
			return;
		}

		const giftDue = totalDue - loyaltyDiscount / 100;

		if (giftDue <= 0) {
			toast.error('Nothing to charge — the booking is fully covered by discounts');
			return;
		}

		let payAmountPence = Math.round(giftDue * 100);
		if (useAccountBalance) {
			const parsedAmt = parseFloat(giftCardPaymentAmount);
			if (isNaN(parsedAmt) || parsedAmt <= 0) {
				toast.error('Please enter a valid payment amount');
				return;
			}
			if (parsedAmt > customerBalance) {
				toast.error('Payment amount exceeds available balance');
				return;
			}
			payAmountPence = Math.round(parsedAmt * 100);
		}

		isProcessingPaymentSync = true;
		status = 'gift-confirming';
		error = null;

		try {
			await applyLoyaltyRedemption();

			const body: {
				amount: number;
				payment_type: string;
				payment_method: string;
				gift_card_id?: string;
			} = {
				amount: payAmountPence,
				payment_type: 'full',
				payment_method: 'giftcard'
			};
			if (!useAccountBalance) {
				body.gift_card_id = giftCardId.replace(/-/g, '');
			}

			const response = await apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
				method: 'POST',
				headers: { 'Content-Type': 'application/json' },
				body: JSON.stringify(body)
			});

			if (!response.ok) {
				const errData = await response.text();
				throw new Error(extractErrorMessage(errData) || 'Failed to process gift card');
			}

			const data = await response.json();
			status = 'success';
			paymentResult = {
				checkout_id: data.checkout_id || data.id || '',
				status: 'COMPLETED',
				amount: data.amount
			};
			toast.success('Gift card payment recorded');
			onComplete(paymentResult);
		} catch (_err) {
			status = 'error';
			error = _err instanceof Error ? _err.message : 'Failed to process gift card';
			toast.error(error ?? 'Unknown error');
		} finally {
			isProcessingPaymentSync = false;
		}
	}

	// Saved cards — fields match the backend SavedCard shape (brand, last_4,
	// exp_month, exp_year), not the old card_brand/card_last4/card_expiry names
	// which rendered blank.
	let savedCards = $state<
		Array<{
			id: string;
			brand: string;
			last_4: string;
			exp_month: number;
			exp_year: number;
			cardholder_name?: string;
			square_card_id?: string;
		}>
	>([]);
	let loadingSavedCards = $state(false);
	let selectedSavedCardId = $state<string | null>(null);

	// Per-attempt idempotency key for saved-card charges: regenerated whenever
	// the (booking.id, selectedSavedCardId, charge amount) tuple changes, so
	// two DISTINCT identical charges get different UUIDs, but reused across
	// retries of the SAME charge so a lost-response retry dedups server-side.
	// Mirrors the TipPayment.svelte tipIdempotencyKey/tipKeyedAmount pattern.
	let savedCardIdempotencyKey = $state('');
	let savedCardKeyedBookingId = $state('');
	let savedCardKeyedCardId = $state('');
	let savedCardKeyedAmount = $state(0);

	async function fetchSavedCards() {
		const targetUserId = booking.user_id ?? booking.user?.id;
		if (!targetUserId) return;
		loadingSavedCards = true;
		savedCards = [];
		selectedSavedCardId = null;
		try {
			const res = await apiFetch(`/api/admin/users/${targetUserId}/payment-methods`);
			if (res.ok) {
				savedCards = await res.json();
			}
		} catch {
			toast.error('Failed to load saved cards');
		} finally {
			loadingSavedCards = false;
		}
	}

	async function handleSavedCardPayment() {
		if (isProcessingPaymentSync) return;
		if (!selectedSavedCardId) {
			toast.error('Please select a saved card');
			return;
		}

		const chargeAmount = Math.round(savedCardAmountNum * 100) - loyaltyDiscount;

		if (isNaN(savedCardAmountNum) || savedCardAmountNum <= 0) {
			toast.error('Please enter a valid amount');
			return;
		}
		if (savedCardAmountNum > totalDue) {
			toast.error(`Amount cannot exceed ${formatCurrency(totalDue)}`);
			return;
		}

		// totalDue can be £0 (fully discounted) and the loyalty discount is
		// applied on top — the effective charge could otherwise be 0 or negative.
		if (chargeAmount <= 0) {
			toast.error('Nothing to charge — the booking is fully covered by discounts');
			return;
		}

		// Reuse the key while the charge context is unchanged (retry of the
		// same charge → server-side dedup); regenerate when the card or amount
		// changes so distinct charges never collapse on one key.
		if (
			!savedCardIdempotencyKey ||
			savedCardKeyedBookingId !== booking.id ||
			savedCardKeyedCardId !== selectedSavedCardId ||
			savedCardKeyedAmount !== chargeAmount
		) {
			savedCardIdempotencyKey = generateUUID();
			savedCardKeyedBookingId = booking.id;
			savedCardKeyedCardId = selectedSavedCardId;
			savedCardKeyedAmount = chargeAmount;
		}

		isProcessingPaymentSync = true;
		status = 'saved-card-processing';
		error = null;

		let responseStatus = 0;
		try {
			await saveServiceOverrides();
			await applyLoyaltyRedemption();

			// Proactive saved-card (ccof) SCA: run the client-side challenge
			// BEFORE the first charge attempt so the first charge carries a
			// fresh verification_token — a naked ccof is never sent to the
			// backend. Only 'sca-unavailable' proceeds token-less (the 2FA gate
			// is the fallback); a cancelled/failed challenge does NOT charge —
			// the operator taps Pay again to re-run it, reusing the SAME cached
			// idempotency key above so the retry dedups instead of double-charging.
			let verificationToken = '';
			status = 'saved-card-waiting-sca';
			try {
				const squareCardId = savedCards.find((c) => c.id === selectedSavedCardId)?.square_card_id;
				const proactive = await runSavedCardSCAProactively({
					amountPence: chargeAmount,
					squareCardId: squareCardId ?? '',
					buyer: {
						givenName: booking.user?.first_name,
						familyName: booking.user?.last_name,
						email: booking.user?.email
					},
					onOutcome: (o) => (lastSCAOutcome = o)
				});
				if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
					status = 'error';
					error = CARD_VERIFICATION_RETRY_MESSAGE;
					toast.error(error);
					return;
				}
				if (proactive.outcome === 'sca-unavailable') {
					// C6: SCA genuinely can't run. Stop the charge — a token-less
					// ccof is never sent — and surface the refusal notice; there
					// is NO 2FA fallback. The operator taps OK to close, or Back
					// to pick a different payment method / retry SCA.
					status = 'saved-card-selecting';
					return;
				}
				verificationToken = proactive.verificationToken ?? '';
			} finally {
				status = 'saved-card-processing';
			}

			const response = await submitPaymentWithRetry(() =>
				apiFetch(`/api/admin/bookings/${booking.id}/payment`, {
					method: 'POST',
					headers: { 'Content-Type': 'application/json' },
					body: JSON.stringify({
						amount: chargeAmount,
						payment_type: 'full',
						payment_method: 'saved_card',
						saved_card_id: selectedSavedCardId,
						// C1: the SCA tokenize-result token is the charge SOURCE
						// (new_card_token) alongside the saved-card ref — never
						// the legacy verification_token.
						...(verificationToken ? { new_card_token: verificationToken } : {}),
						idempotency_key: savedCardIdempotencyKey
					})
				})
			);

			if (!response.ok) {
				responseStatus = response.status;
				const errData = await response.text();
				// Overflow guard: a 400 carrying the backend's
				// `overflow_tip_confirmation_required` code means the charge
				// exceeds the booking's remaining balance (stale data). Park the
				// rejected request and surface the Confirm/Cancel prompt instead
				// of a dead-end 400; Confirm resends the SAME body with the flag.
				if (isOverflowTipConfirmationRequired(errData)) {
					overflowConfirm = {
						amountPence: chargeAmount,
						overflowPence: Math.max(0, chargeAmount - Math.round(netTotal * 100)),
						body: {
							amount: chargeAmount,
							payment_type: 'full',
							payment_method: 'saved_card',
							saved_card_id: selectedSavedCardId,
							...(verificationToken ? { new_card_token: verificationToken } : {}),
							idempotency_key: savedCardIdempotencyKey
						}
					};
					return;
				}
				// A 402 verification-required here means the fresh proactive
				// token was stale/expired at Square — the charge did NOT land.
				// Surface the SCA-first guidance; the operator taps Pay again and
				// a fresh challenge runs under the SAME cached idempotency key
				// (no double-charge). A plain decline 402 shows the normal error.
				const err = new Error(
					extractErrorMessage(errData) || 'Failed to process saved card payment'
				);
				(err as { bodyText?: string }).bodyText = errData;
				throw err;
			}

			const data = await response.json();
			status = 'success';
			paymentResult = {
				// Saved-card charges return payment_id (a DB payment row, not a
				// Square checkout) — fall back to the other keys for the
				// terminal/checkout responses.
				checkout_id: data.payment_id || data.checkout_id || data.id || '',
				status: 'COMPLETED',
				card_brand: data.card_brand,
				last4: data.card_last4,
				amount: data.amount
			};
			// The charge succeeded — clear the cached key so the next (distinct)
			// charge gets a fresh UUID and can't be deduped against this one.
			savedCardIdempotencyKey = '';
			savedCardKeyedAmount = 0;
			toast.success('Saved card payment successful');
			onComplete(paymentResult);
		} catch (_err) {
			status = 'error';
			// A 402 carrying the structured verification-required signal (or the
			// dev/mock text parity) surfaces the SCA-first guidance. A plain
			// decline 402 shows the normal decline error — it must not be
			// relabeled "requires verification".
			let msg = _err instanceof Error ? _err.message : 'Failed to process saved card payment';
			const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
			if (isVerificationRequiredSignal(responseStatus, bodyText)) {
				// A verification-required 402 means the backend did NOT accept
				// the token — surface the SCA-first guidance and let the
				// operator retry.
				msg = VERIFICATION_REQUIRED_MESSAGE;
			}
			// A DEFINITIVE 402 (declined card / stale token) means the charge did
			// NOT land — Square's idempotency key would otherwise reject a retry
			// that re-runs SCA and mints a fresh token. Regenerate the key on 402
			// so the next Pay click gets a fresh key + fresh pending row. Keep it
			// on 503/network (ambiguous).
			if (responseStatus === 402) {
				savedCardIdempotencyKey = '';
				savedCardKeyedBookingId = '';
				savedCardKeyedCardId = '';
				savedCardKeyedAmount = 0;
			}
			error = msg;
			toast.error(msg);
		} finally {
			isProcessingPaymentSync = false;
		}
	}

	$effect(() => {
		if (selectedMethod === 'cash') {
			cashAmount = cashDue.toFixed(2);
			extraAsTip = false;
		}
		if (selectedMethod === 'giftcard') {
			giftCardId = '';
		}
		if (selectedMethod === PAYMENT_METHOD_SAVED_CARD) {
			fetchSavedCards();
		}
	});
</script>

<Dialog.Root
	open={true}
	onOpenChange={(open) => {
		if (open) return;
		// ESC/overlay while a charge is in flight must not close the modal — the
		// charge may still land. ESC while the overflow-confirm prompt is showing
		// dismisses the prompt (back to the amount-editing form), mirroring the
		// customer modal, instead of closing the whole flow.
		if (isChargeInFlight(status)) return;
		if (overflowConfirm) {
			cancelOverflowConfirmation();
			return;
		}
		handleClose();
	}}
>
	<Dialog.Content class="sm:max-w-lg">
		<Dialog.Header>
			<Dialog.Title class="text-xl font-semibold">Take Payment</Dialog.Title>
		</Dialog.Header>

		{#if overflowConfirm}
			<div class="space-y-4">
				<!-- Overpayment confirmation: the backend rejected the payment because
				 the booking's remaining balance has changed since it was loaded
				 (stale data). The excess over the remaining balance will be
				 recorded as a tip once confirmed. Shared component with the
				 customer payment modal and the booking-flow deposit step so the
				 admin surface can't drift. -->
				<OverflowTipConfirm
					overflowPence={overflowConfirm.overflowPence}
					loading={status === 'saved-card-processing'}
					onConfirm={confirmOverflowPayment}
					onCancel={cancelOverflowConfirmation}
				/>
				<Button variant="ghost" onclick={handleClose} class="w-full">Close</Button>
			</div>
		{:else if status === 'idle'}
			<div class="space-y-4">
				<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
					<div class="mb-3 text-sm font-semibold text-gray-700">Services</div>
					<div
						class="grid grid-cols-1 {(booking.services?.length ?? 0) > 1
							? 'sm:grid-cols-2'
							: ''} gap-3"
					>
						{#each booking.services ?? [] as service, i (service.service_id ?? `svc-${i}`)}
							<div class="rounded-lg border bg-white p-3">
								<div class="mb-2 text-sm font-medium">
									{service.service_name || 'Unknown Service'}
								</div>
								<div class="flex min-w-0 items-center gap-2">
									<span class="text-xs text-gray-500">£</span>
									<input
										type="text"
										inputmode="decimal"
										class="flex h-10 w-24 min-w-0 rounded-md border border-input bg-background px-2 py-1 text-base ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none md:text-sm"
										value={serviceOverrides[service.service_id]?.price ??
											service.price?.toFixed(2) ??
											'0.00'}
										oninput={(e) => handlePriceInput(service.service_id, e.currentTarget.value)}
									/>
									{#if serviceOverrides[service.service_id] && Math.abs(parseFloat(serviceOverrides[service.service_id].price) - serviceOverrides[service.service_id].originalPrice) > 0.01}
										<span class="min-w-0 text-xs text-amber-600">
											(was {formatCurrency(serviceOverrides[service.service_id].originalPrice)})
										</span>
									{/if}
								</div>
							</div>
						{/each}
					</div>
				</div>

				{#if loyaltyEligible}
					<div class="rounded-md border border-fuchsia-100 bg-fuchsia-50 p-4">
						<div class="flex items-start gap-3">
							<Checkbox
								id="use-loyalty-admin"
								bind:checked={useLoyalty}
								disabled={status !== 'idle'}
							/>
							<label for="use-loyalty-admin" class="cursor-pointer select-none">
								<div class="text-sm font-medium text-fuchsia-900">Use Loyalty Stamp Card</div>
								<div class="mt-0.5 text-xs text-fuchsia-700">
									{Math.floor(stamps / 10)} full card{Math.floor(stamps / 10) === 1 ? '' : 's'} available
									&middot; {Math.round(POLICY.LOYALTY_DISCOUNT_RATE * 100)}% off ({formatCurrency(
										Math.round(booking.total_amount * 100 * POLICY.LOYALTY_DISCOUNT_RATE) / 100
									)})
								</div>
							</label>
						</div>
					</div>
				{/if}

				{#if booking.discounts && booking.discounts.length > 0}
					<div class="rounded-md border border-gray-100 bg-gray-50/50 p-4">
						<div class="mb-3 flex items-center justify-between">
							<div class="flex items-center gap-1.5 text-sm font-semibold text-gray-800">
								<svg
									class="h-4 w-4 text-gray-500"
									viewBox="0 0 24 24"
									fill="none"
									stroke="currentColor"
									stroke-width="2"
								>
									<path
										d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"
									></path>
									<line x1="7" y1="7" x2="7.01" y2="7"></line>
								</svg>
								Applied Discounts
							</div>
							<span
								class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600"
							>
								{((discountSum / subtotal) * 100).toFixed(0)}% Off Total
							</span>
						</div>
						<div class="space-y-2 text-sm">
							{#each booking.discounts as d (d.id)}
								<div class="flex items-center justify-between text-gray-600">
									<div class="flex items-center gap-1.5">
										<span class="h-1.5 w-1.5 rounded-full bg-fuchsia-400"></span>
										<span>
											{#if d.discount_source === 'loyalty'}
												Loyalty Stamp Card (10% Off)
											{:else if d.campaign_name}
												{d.campaign_name}
											{:else}
												Promo Campaign ({d.discount_percent}% Off)
											{/if}
										</span>
									</div>
									<span class="font-medium text-gray-900">-{formatCurrency(d.discount_amount)}</span
									>
								</div>
							{/each}
						</div>
					</div>
				{/if}

				<div
					class="flex items-center justify-between rounded-md border border-gray-200 bg-white p-4"
				>
					{#if tipEnabled}
						<span class="text-base font-semibold text-gray-700">Subtotal (pre-tip)</span>
						<div class="flex items-baseline gap-2.5">
							{#if discountSum > 0.01}
								<span class="text-sm font-medium text-gray-400 line-through"
									>{formatCurrency(subtotal)}</span
								>
							{/if}
							<span class="text-xl font-bold text-gray-900">{formatCurrency(netTotal)}</span>
						</div>
					{:else}
						<span class="text-base font-semibold text-gray-700">Total</span>
						<div class="flex items-baseline gap-2.5">
							{#if discountSum > 0.01}
								<span class="text-sm font-medium text-gray-400 line-through"
									>{formatCurrency(subtotal)}</span
								>
							{/if}
							<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
						</div>
					{/if}
				</div>

				{#if nothingToCharge}
					<p class="rounded-md border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
						Nothing to charge — the booking is fully covered by discounts or prior payments.
					</p>
				{/if}

				{#if amountPaidPence > 0}
					<div
						class="flex items-center justify-between rounded-md border border-green-200 bg-green-50 p-3"
					>
						<span class="text-sm font-medium text-green-800">Already paid</span>
						<span class="text-base font-bold text-green-800"
							>{formatCurrency(amountPaidPence / 100)}</span
						>
					</div>
				{/if}

				{#if discountPreview?.eligible && discountPreview.discounts.length > 0}
					<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
						{#each discountPreview.discounts as d (d.name)}
							<div class="flex justify-between text-sm">
								<span class="text-gray-600">{d.name}</span>
								<span class="font-medium text-green-700"
									>-{formatCurrency(Math.round(d.amount * 100) / 100)}</span
								>
							</div>
						{/each}
					</div>
				{/if}

				{#if tipEnabled}
					<div class="rounded-md border border-green-200 bg-green-50 p-3">
						<div class="flex justify-between">
							<span class="text-sm font-medium text-green-800">
								Total with Tip ({tipDisplay})
							</span>
							<span class="text-lg font-bold text-green-800">
								{formatCurrency(totalWithTip)}
							</span>
						</div>
						<div class="mt-1 flex justify-between text-xs font-medium text-green-700">
							<span>Tip amount</span>
							<span>+{formatCurrency(tipDelta)}</span>
						</div>
					</div>
				{/if}

				<div
					class="grid grid-cols-2 gap-3 {savedCards.length > 0
						? 'sm:grid-cols-4'
						: 'sm:grid-cols-3'}"
				>
					<button
						type="button"
						disabled={nothingToCharge}
						class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
						'card'
							? 'border-input bg-fuchsia-100 text-foreground'
							: 'border-input hover:bg-fuchsia-50'}"
						onclick={() => {
							selectedMethod = 'card';
							status = 'selecting';
						}}
					>
						<svg
							class="mx-auto mb-2 h-8 w-8"
							viewBox="0 0 24 24"
							fill="none"
							stroke="currentColor"
							stroke-width="2"
						>
							<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
							<line x1="1" y1="10" x2="23" y2="10" />
						</svg>
						Card
					</button>
					<button
						type="button"
						disabled={nothingToCharge}
						class="rounded-lg border py-6 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 {selectedMethod ===
						'cash'
							? 'border-input bg-fuchsia-100 text-foreground'
							: 'border-input hover:bg-fuchsia-50'}"
						onclick={() => {
							selectedMethod = 'cash';
							status = 'cash-entering';
						}}
					>
						<svg
							class="mx-auto mb-2 h-8 w-8"
							viewBox="0 0 24 24"
							fill="none"
							stroke="currentColor"
							stroke-width="2"
						>
							<line x1="12" y1="1" x2="12" y2="23" />
							<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
						</svg>
						Cash
					</button>
					{#if savedCards.length > 0}
						<button
							type="button"
							disabled={nothingToCharge}
							class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
							PAYMENT_METHOD_SAVED_CARD
								? 'border-input bg-fuchsia-100 text-foreground'
								: 'border-input hover:bg-fuchsia-50'}"
							onclick={() => {
								selectedMethod = PAYMENT_METHOD_SAVED_CARD;
								status = 'saved-card-selecting';
							}}
						>
							<svg
								class="mx-auto mb-2 h-8 w-8"
								viewBox="0 0 24 24"
								fill="none"
								stroke="currentColor"
								stroke-width="2"
							>
								<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
								<path d="M6 10h12" />
								<path d="M6 14h6" />
							</svg>
							Saved Card
						</button>
					{/if}
					<button
						type="button"
						disabled={nothingToCharge}
						class="hidden rounded-lg border py-6 text-center text-sm font-semibold transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 sm:block {selectedMethod ===
						'giftcard'
							? 'border-input bg-fuchsia-100 text-foreground'
							: 'border-input hover:bg-fuchsia-50'}"
						onclick={() => {
							selectedMethod = 'giftcard';
							status = 'gift-entering';
						}}
					>
						<svg
							class="mx-auto mb-2 h-8 w-8"
							viewBox="0 0 24 24"
							fill="none"
							stroke="currentColor"
							stroke-width="2"
						>
							<polyline points="20 12 20 22 4 22 4 12" />
							<rect x="2" y="7" width="20" height="5" />
							<line x1="12" y1="22" x2="12" y2="7" />
							<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z" />
							<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z" />
						</svg>
						Gift Card
					</button>
				</div>

				<div class="flex flex-wrap gap-3 sm:hidden">
					{#if savedCards.length > 0}
						<button
							type="button"
							disabled={nothingToCharge}
							class="min-h-11 w-full text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
							onclick={() => {
								selectedMethod = PAYMENT_METHOD_SAVED_CARD;
								status = 'saved-card-selecting';
							}}
						>
							Pay with Saved Card
						</button>
					{/if}
					<button
						type="button"
						disabled={nothingToCharge}
						class="min-h-11 w-full text-sm text-gray-600 underline hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-50"
						onclick={() => {
							selectedMethod = 'giftcard';
							status = 'gift-entering';
						}}
					>
						Pay with Gift Card
					</button>
				</div>

				<div class="flex gap-3">
					<Button variant="ghost" onclick={handleClose} class="min-h-11 flex-1">Cancel</Button>
				</div>
			</div>
		{:else if status === 'selecting'}
			<div class="space-y-4">
				<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
					{#if tipEnabled}
						<span class="text-base font-semibold text-gray-700">Subtotal (pre-tip)</span>
						<span class="text-xl font-bold text-gray-900">{formatCurrency(netTotal)}</span>
					{:else}
						<span class="text-base font-semibold text-gray-700">Total</span>
						<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
					{/if}
				</div>

				{#if campaignDiscountPence(discountPreview) > 0}
					<p class="rounded-md border border-green-200 bg-green-50 p-2.5 text-xs text-green-800">
						Discount {formatCurrency(campaignDiscountPence(discountPreview) / 100)} pending — tip will
						be calculated on the discounted amount.
					</p>
				{/if}

				<div class="space-y-3">
					<span class="text-sm font-medium text-gray-700">Add a Tip</span>
					<div class="grid grid-cols-3 gap-2">
						{#each tipPercentages as tip (tip.pct)}
							<button
								type="button"
								class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors hover:bg-fuchsia-50 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {selectedTipPercent ===
								tip.pct
									? 'border-input bg-fuchsia-100 text-foreground'
									: 'border-input'}"
								onclick={() => selectTipPercent(tip.pct)}
							>
								<div>{tip.pct}%</div>
								<div class="text-xs font-normal text-gray-500">{formatCurrency(tip.amount)}</div>
							</button>
						{/each}
					</div>
					<div class="relative">
						<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
						<Input
							type="text"
							inputmode="decimal"
							placeholder="Custom tip amount"
							value={customTipAmount}
							oninput={handleCustomTipInput}
							class="pl-7"
						/>
					</div>
				</div>

				{#if tipEnabled}
					<div class="rounded-md border border-green-200 bg-green-50 p-3">
						<div class="flex justify-between">
							<span class="text-sm font-medium text-green-800">
								Total with Tip ({tipDisplay})
							</span>
							<span class="text-lg font-bold text-green-800">
								{formatCurrency(totalWithTip)}
							</span>
						</div>
						<div class="mt-1 flex justify-between text-xs font-medium text-green-700">
							<span>Tip amount</span>
							<span>+{formatCurrency(tipDelta)}</span>
						</div>
					</div>
				{/if}

				<div class="border-t pt-3">
					<button
						type="button"
						onclick={() => (showPartialCharge = !showPartialCharge)}
						class="text-sm text-gray-600 hover:text-gray-800 hover:underline"
					>
						{showPartialCharge ? '− Hide partial charge' : '+ Partial charge'}
					</button>
					{#if showPartialCharge}
						<div class="mt-3">
							<label for="card-amount" class="text-sm font-medium text-gray-700">
								Amount to Charge
							</label>
							<div class="relative mt-1">
								<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
								<Input
									id="card-amount"
									type="text"
									inputmode="decimal"
									step="0.01"
									placeholder={totalDue.toFixed(2)}
									value={cardPaymentAmount}
									oninput={handleCardAmountInput}
									class="pl-7"
									disabled={status !== 'selecting'}
								/>
							</div>
							{#if cardValidationError}
								<p class="mt-1 text-sm text-red-600">{cardValidationError}</p>
							{/if}
							<p class="mt-1 text-xs text-gray-500">
								Leave blank to charge full amount ({formatCurrency(totalDue)})
							</p>
						</div>
					{/if}
				</div>

				<div class="flex gap-3">
					<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
					<Button
						onclick={handleCardPayment}
						class="min-h-11 flex-1"
						disabled={nothingToCharge || !cardAmountValid}
					>
						{cardPaymentAmount === '' ? 'Charge Card' : `Charge ${formatCurrency(cardAmountNum)}`}
					</Button>
				</div>
			</div>
		{:else if status === 'card-processing' || status === 'card-polling'}
			<div class="flex flex-col items-center justify-center py-8">
				<div
					class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
				></div>
				<p class="text-lg font-medium text-gray-700">Waiting for customer to tap card...</p>
				<p class="mt-2 text-sm text-gray-500">This may take a few moments</p>
			</div>
		{:else if status === 'cash-entering'}
			<div class="space-y-4">
				<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
					<span class="text-base font-semibold text-gray-700">Total Due</span>
					<span class="text-xl font-bold text-gray-900">{formatCurrency(cashDue)}</span>
				</div>

				<div>
					<label for="cash-amount" class="text-sm font-medium text-gray-700"> Cash Received </label>
					<div class="relative mt-1">
						<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
						<Input
							id="cash-amount"
							type="text"
							inputmode="decimal"
							value={cashAmount}
							oninput={handleCashInput}
							class="pl-7 text-lg font-semibold"
						/>
					</div>
				</div>

				{#if cashAmountNum >= cashDue}
					<div class="rounded-md border border-green-200 bg-green-50 p-4">
						<div class="flex justify-between">
							<span class="text-sm font-medium text-green-800">Change Due</span>
							<span class="text-lg font-bold text-green-800">{formatCurrency(changeDue)}</span>
						</div>
						{#if changeDue > 0}
							<div class="mt-2 flex items-center gap-2">
								<Checkbox id="keep-change" bind:checked={extraAsTip} />
								<label for="keep-change" class="text-sm text-green-700">
									Keep {formatCurrency(changeDue)} as tip
								</label>
							</div>
						{/if}
					</div>
				{/if}

				<div class="flex gap-3">
					<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
					<Button
						onclick={handleCashPayment}
						class="min-h-11 flex-1"
						disabled={cashAmountNum < cashDue || nothingToCharge}
					>
						Confirm Cash
					</Button>
				</div>
			</div>
		{:else if status === 'cash-confirming'}
			<div class="flex flex-col items-center justify-center py-8">
				<div
					class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
				></div>
				<p class="text-lg font-medium text-gray-700">Processing cash payment...</p>
			</div>
		{:else if status === 'gift-entering'}
			<div class="space-y-4">
				<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
					<span class="text-base font-semibold text-gray-700">Total Due</span>
					<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
				</div>

				{#if (booking.user_id ?? booking.user?.id) && customerBalance > 0}
					<div class="space-y-2">
						<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
							>Source</span
						>
						<div class="grid grid-cols-2 gap-2">
							<button
								type="button"
								class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {useAccountBalance
									? 'border-input bg-fuchsia-100 text-foreground'
									: 'border-gray-200 hover:bg-gray-50'}"
								onclick={() => (useAccountBalance = true)}
							>
								Account Balance ({formatCurrency(customerBalance)})
							</button>
							<button
								type="button"
								class="rounded-lg border py-2 text-center text-xs font-medium transition-colors {!useAccountBalance
									? 'border-input bg-fuchsia-100 text-foreground'
									: 'border-gray-200 hover:bg-gray-50'}"
								onclick={() => (useAccountBalance = false)}
							>
								Physical Gift Card Code
							</button>
						</div>
					</div>
				{/if}

				{#if useAccountBalance}
					<div>
						<label for="giftcard-amount" class="text-sm font-medium text-gray-700"
							>Amount to pay with Balance (£)</label
						>
						<div class="mt-1 flex gap-2">
							<Input
								id="giftcard-amount"
								type="text"
								inputmode="decimal"
								value={giftCardPaymentAmount}
								oninput={(e) => (giftCardPaymentAmount = (e.target as HTMLInputElement).value)}
								class="flex-1 font-mono text-lg"
							/>
							<Button
								variant="outline"
								size="sm"
								onclick={() => {
									giftCardPaymentAmount = Math.min(customerBalance, totalDue).toFixed(2);
								}}
								class="shrink-0 text-xs"
							>
								Full Balance
							</Button>
						</div>
						<p class="mt-1 text-xs text-gray-500">
							Available balance: {formatCurrency(customerBalance)}. Maximum of total due or balance
							can be used.
						</p>
					</div>
				{:else}
					<div>
						<label for="gift-card-id" class="text-sm font-medium text-gray-700">
							Gift Card Code
						</label>
						<Input
							id="gift-card-id"
							type="text"
							inputmode="text"
							value={giftCardId}
							oninput={handleGiftCardInput}
							placeholder="XXXX-XXXX-XXXX"
							maxlength={14}
							class="mt-1 font-mono text-base tracking-wide"
						/>
						<p class="mt-1 text-xs text-gray-500">
							Enter the 12-character code printed on the gift card
						</p>
					</div>
				{/if}

				<div class="flex gap-3">
					<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
					<Button
						onclick={handleGiftCardPayment}
						class="min-h-11 flex-1"
						disabled={!giftCardValid || nothingToCharge}
					>
						Apply Gift Card
					</Button>
				</div>
			</div>
		{:else if status === 'gift-confirming'}
			<div class="flex flex-col items-center justify-center py-8">
				<div
					class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
				></div>
				<p class="text-lg font-medium text-gray-700">Processing gift card...</p>
			</div>
		{:else if status === 'saved-card-selecting'}
			<div class="space-y-4">
				<div class="flex justify-between rounded-md border border-gray-200 bg-white p-4">
					<span class="text-base font-semibold text-gray-700">Total Due</span>
					<span class="text-xl font-bold text-gray-900">{formatCurrency(totalDue)}</span>
				</div>

				{#if loadingSavedCards}
					<div class="flex justify-center py-8">
						<div
							class="h-8 w-8 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
						></div>
					</div>
				{:else if savedCards.length === 0}
					<div class="rounded-md border border-gray-200 bg-gray-50 p-6 text-center">
						<p class="text-sm text-gray-600">No saved cards found for this customer.</p>
						<p class="mt-1 text-xs text-gray-500">
							Add a card via Square Dashboard or use another payment method.
						</p>
					</div>
				{:else}
					<div class="space-y-2">
						<span class="block text-xs font-semibold tracking-wider text-gray-500 uppercase"
							>Select a Saved Card</span
						>
						{#each savedCards as card (card.id)}
							<button
								type="button"
								class="w-full rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none {selectedSavedCardId ===
								card.id
									? 'border-input bg-fuchsia-100'
									: 'border-gray-200 hover:bg-gray-50'}"
								onclick={() => (selectedSavedCardId = card.id)}
							>
								<div class="flex items-center justify-between">
									<div class="flex items-center gap-2">
										<svg
											class="h-5 w-5 text-gray-500"
											viewBox="0 0 24 24"
											fill="none"
											stroke="currentColor"
											stroke-width="2"
										>
											<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
											<line x1="1" y1="10" x2="23" y2="10" />
										</svg>
										<span class="font-medium text-gray-900">{card.brand} ••••{card.last_4}</span>
									</div>
									<span class="text-xs text-gray-500"
										>{String(card.exp_month).padStart(2, '0')}/{card.exp_year}</span
									>
								</div>
								{#if card.cardholder_name}
									<div class="mt-1 text-xs text-gray-500">{card.cardholder_name}</div>
								{/if}
							</button>
						{/each}
					</div>

					<div class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3">
						<svg
							class="mt-0.5 h-4 w-4 shrink-0 text-amber-600"
							viewBox="0 0 24 24"
							fill="none"
							stroke="currentColor"
							stroke-width="2"
						>
							<circle cx="12" cy="12" r="10" />
							<line x1="12" y1="8" x2="12" y2="12" />
							<line x1="12" y1="16" x2="12.01" y2="16" />
						</svg>
						<p class="text-xs text-amber-800">
							Your card issuer will ask you to approve this payment in your banking app.
						</p>
					</div>
				{/if}

				<!-- C6: SCA-unavailable refusal — the ONLY behaviour on a genuine
					 sca-unavailable outcome: the charge cannot complete and the
					 customer must pay online later (no 2FA code fallback). -->
				<ScaFallbackConsentDialog
					open={shouldShowSCARefusal(lastSCAOutcome)}
					onOk={() => {
						lastSCAOutcome = '';
						handleClose();
					}}
				/>

				{#if selectedSavedCardId}
					<div class="border-t pt-3">
						<button
							type="button"
							onclick={() => (showPartialCharge = !showPartialCharge)}
							class="text-sm text-gray-600 hover:text-gray-800 hover:underline"
						>
							{showPartialCharge ? '− Hide partial charge' : '+ Partial charge'}
						</button>
						{#if showPartialCharge}
							<div class="mt-3">
								<label for="saved-card-amount" class="text-sm font-medium text-gray-700">
									Amount to Charge
								</label>
								<div class="relative mt-1">
									<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
									<Input
										id="saved-card-amount"
										type="text"
										inputmode="decimal"
										step="0.01"
										placeholder={totalDue.toFixed(2)}
										value={savedCardPaymentAmount}
										oninput={handleSavedCardAmountInput}
										class="pl-7"
										disabled={status !== 'saved-card-selecting'}
									/>
								</div>
								{#if savedCardValidationError}
									<p class="mt-1 text-sm text-red-600">{savedCardValidationError}</p>
								{/if}
								<p class="mt-1 text-xs text-gray-500">
									Leave blank to charge full amount ({formatCurrency(totalDue)})
								</p>
							</div>
						{/if}
					</div>
				{/if}

				<div class="flex gap-3">
					<Button variant="ghost" onclick={resetToSelect} class="min-h-11 flex-1">Back</Button>
					<Button
						onclick={handleSavedCardPayment}
						class="min-h-11 flex-1"
						disabled={
							!selectedSavedCardId || nothingToCharge || !savedCardAmountValid
						}
					>
						{savedCardPaymentAmount === ''
							? 'Charge Saved Card'
							: `Charge ${formatCurrency(savedCardAmountNum)}`}
					</Button>
				</div>
			</div>
		{:else if status === 'saved-card-processing' || status === 'saved-card-waiting-sca'}
			<div class="flex flex-col items-center justify-center py-8">
				<div
					class="mb-4 h-12 w-12 animate-spin rounded-full border-4 border-gray-200 border-t-primary"
				></div>
				{#if status === 'saved-card-waiting-sca'}
					<p class="text-lg font-medium text-gray-700">
						Waiting for customer to approve in their banking app…
					</p>
					<p class="mt-2 text-sm text-gray-500">
						The customer may need to approve this payment in their banking app
					</p>
				{:else}
					<p class="text-lg font-medium text-gray-700">Processing saved card payment...</p>
				{/if}
			</div>
		{:else if status === 'error' && error}
			<div class="space-y-4">
				<div class="rounded-md border border-red-200 bg-red-50 p-3">
					<p class="text-sm text-red-800">{error}</p>
				</div>
				<div class="flex gap-3">
					<Button variant="ghost" onclick={handleClose} class="min-h-11 flex-1">Close</Button>
					<Button onclick={resetToSelect} class="min-h-11 flex-1">Try Again</Button>
				</div>
			</div>
		{:else if status === 'success' && paymentResult}
			<div class="space-y-4">
				<div class="flex flex-col items-center justify-center py-4">
					<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100">
						<svg
							xmlns="http://www.w3.org/2000/svg"
							class="h-8 w-8 text-green-600"
							viewBox="0 0 20 20"
							fill="currentColor"
						>
							<path
								fill-rule="evenodd"
								d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
								clip-rule="evenodd"
							/>
						</svg>
					</div>
					<h3 class="text-xl font-semibold text-gray-900">Payment Successful</h3>
				</div>

				<div class="rounded-md border border-gray-200 bg-gray-50 p-4">
					<div class="space-y-3">
						<div class="flex justify-between">
							<span class="text-sm text-gray-600">Amount</span>
							<span class="font-semibold text-gray-900">
								{formatCurrency(paymentResult.amount / 100)}
							</span>
						</div>
						{#if paymentResult.card_brand}
							<div class="flex justify-between">
								<span class="text-sm text-gray-600">Card</span>
								<span class="font-medium text-gray-900">
									{paymentResult.card_brand} ****{paymentResult.last4}
								</span>
							</div>
						{/if}
						<div class="flex justify-between">
							<span class="text-sm text-gray-600">Status</span>
							<span class="font-medium text-green-600">Completed</span>
						</div>
					</div>
				</div>

				<Button onclick={handleSuccessDone} class="min-h-11 w-full">Done</Button>
			</div>
		{/if}
	</Dialog.Content>
</Dialog.Root>
