<script lang="ts">
	import { onMount, onDestroy } 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 } from '$lib/types/booking';
	import CardSelection from '$lib/components/payments/CardSelection.svelte';
	import TwoFactorCodeInput from '$lib/components/payments/TwoFactorCodeInput.svelte';
	import OverflowTipConfirm from '$lib/components/payments/OverflowTipConfirm.svelte';
	import PolicyPopover from '$lib/components/ui/policyPopover.svelte';
	import { authStore } from '$lib/stores/auth.svelte';
	import { savedCardsStore } from '$lib/stores/savedCards.svelte';
	import { useTwoFactorCodeForSavedCard } from '$lib/stores/twoFactorCode.svelte';
	import { apiFetch } from '$lib/utils/api';
	import { generateUUID } from '$lib/utils/uuid';
	import {
		CARD_VERIFICATION_RETRY_MESSAGE,
		campaignDiscountPence,
		depositChargePence,
		isNonceStale,
		isOverflowTipConfirmationRequired,
		isTwoFactorVerificationGateFailure,
		isVerificationRequiredSignal,
		sanitizeDecimalInput,
		shouldFallbackTo2FA,
		submitPaymentWithRetry,
		VERIFICATION_REQUIRED_MESSAGE
	} from '$lib/square/square';
	import {
		tokenizeSavedCardWithVerification,
		type SavedCardVerificationOutcome,
		type SavedCardVerificationResult
	} from '$lib/components/payments/SquareCardInput.svelte';

	const LOYALTY_DISCOUNT_RATE = 0.1;

	interface Props {
		booking: Booking;
		onClose: () => void;
		onComplete: () => void;
		canSaveCards?: boolean;
		defaultPaymentType?: 'full' | 'partial' | 'deposit';
	}

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

	// Explicit consent: whether the new card is saved for next time. Toggled by
	// the checkbox inside CardSelection; defaults to false (opt-in).
	let saveCard = $state(false);

	// B6/B10: saved-card charges (and saving a new card for reuse) require the
	// customer's current 2FA verification code whenever the backend enforces the
	// gate. The input is surfaced at the charge step; the new-card (nonce) path
	// keeps its own SCA via Square tokenizeWithVerification. Shared
	// two-factor-code state (code, reveal, show/missing derivations, "Request a
	// new code" handler) — see $lib/stores/twoFactorCode.svelte.ts.
	const savedCardChargeRequires2FACode = $derived(authStore.savedCardChargeRequires2FACode);
	const twoFactorEnabled = $derived(!!authStore.currentUser?.twoFactorEnabled);
	// Outcome of the last saved-card SCA attempt: 'sca-unavailable' demotes 2FA
	// from backup to the only available gate (scaAvailable → false); every other
	// outcome keeps SCA primary for the next retry.
	let lastSCAOutcome = $state('');
	// True while the proactive saved-card SCA challenge (card.tokenize with
	// verificationDetails) is in flight — the challenge is out-of-band (the
	// buyer approves in their banking app), so the form shows a waiting panel
	// and blocks modal close until it resolves.
	let waitingForSCA = $state(false);
	const twoFactor = useTwoFactorCodeForSavedCard({
		enabled: () => twoFactorEnabled,
		gateActive: () => savedCardChargeRequires2FACode && (selectedCardId !== '' || saveCard),
		scaAvailable: () => !shouldFallbackTo2FA(lastSCAOutcome)
	});

	type PaymentStatus = 'idle' | 'processing' | 'success' | 'error';

	let status = $state<PaymentStatus>('idle');
	let error = $state<string | null>(null);

	// Cached idempotency key per payment attempt (amount + type + card): reused
	// on retry so a lost-response retry dedups instead of double-charging,
	// regenerated when any of those change. Matches the tip-flow pattern.
	let payIdempotencyKey = $state('');
	let payKeyedAmount = $state(0);
	let payKeyedType = $state('');
	let payKeyedCard = $state('');
	// Cached nonce for the new-card form: tokenization is one-shot, so a retry
	// reuses this token instead of re-tokenizing (backend idempotency dedups).
	let newCardNonce = $state('');
	// Cached SCA verification token paired with newCardNonce (both one-shot,
	// reused together on retry). The verification token is amount-bound, so a
	// changed payment amount invalidates the cached pair.
	let newCardVerificationToken = $state('');
	let newCardTokenAmount = $state(0);
	// Epoch ms when the cached pair was tokenized — Square nonces and SCA
	// verification tokens expire after ~5 minutes, so a stale pair is discarded
	// on late retries and re-tokenized instead of rejected by Square.
	let newCardTokenizedAt = $state(0);
	// Save intent at tokenization time: the SCA verification token is bound to
	// CHARGE vs CHARGE_AND_STORE, so toggling the save-card checkbox after a
	// tokenize must force a fresh tokenization rather than reuse a token minted
	// with the wrong intent.
	let newCardTokenizedForSaveCard = $state(false);
	let paymentResult = $state<{
		id: string;
		amount: number;
		card_brand?: string;
		card_last4?: string;
		payment_type: string;
	} | null>(null);

	// Card selection state — cards and loading live in the shared savedCards
	// store so all payment surfaces fetch /api/user/payment-methods identically.
	let selectedCardId = $state('');
	let cardSelectionValid = $state(false);
	let cardSelection = $state<CardSelection | null>(null);

	let stamps = $state(0);
	let useLoyalty = $state(false);

	// Partial payment amount (in pounds, user enters)
	let partialAmount = $state<string>('');
	let lastValidPartialAmount = $state<string>('');

	// Campaign discount preview — fetched on mount to show eligible discounts
	let discountPreview = $state<{
		eligible: boolean;
		discounts: Array<{ source: string; name: string; percent: number; amount: number }>;
		original_total: number;
		discounted_total: number;
	} | null>(null);

	// Derived values
	const depositOutstanding = $derived(booking.deposit_required && !booking.deposit_paid);

	// Auto-select a sensible default payment type based on the booking's deposit
	// state. The backend will split the charge into deposit + non-deposit records
	// when appropriate, so this choice mainly controls the button label and amount.
	const defaultType = $derived(defaultPaymentType ?? (depositOutstanding ? 'deposit' : 'full'));
	// eslint-disable-next-line svelte/prefer-writable-derived
	let paymentType = $state<'full' | 'partial' | 'deposit'>('full');
	$effect(() => {
		paymentType = defaultType as 'full' | 'partial' | 'deposit';
	});

	// Payment lock state
	let lockTimer = $state(-1);
	let lockAcquired = $state(false);
	let lockInterval: ReturnType<typeof setInterval> | null = null;
	let countdownInterval: ReturnType<typeof setInterval> | null = null;

	const servicesSubtotal = $derived(
		(booking.services ?? []).reduce((sum, s) => sum + (s.price || 0), 0)
	);
	const discountSum = $derived(
		(booking.discounts ?? []).reduce((sum, d) => sum + d.discount_amount, 0)
	);

	const totalPaid = $derived(
		booking.payments
			?.filter((p) => p.status === 'completed')
			.reduce((sum, p) => sum + p.amount, 0) || 0
	);

	// Tip-excluding remaining balance in pounds, mirroring the backend's
	// GetBookingRemainingBalancePence (see backend/handlers/payments/service.go):
	// total − completed non-tip payments + completed refunds, clamped to the
	// booking total and floored at 0. The backend rejects an unconfirmed
	// pre-start overpayment when req.Amount > this value, and records the excess
	// (req.Amount − remainingPence) as a tip once confirmed — so the
	// overflow-confirmation prompt shows exactly that. A completed TIP must not
	// reduce what the customer can still pay for the booking itself (gratuity,
	// not booking credit), so this deliberately differs from `totalPaid` (which
	// includes tips and drives the scenario labels / "Amount Paid" row).
	const remainingBalance = $derived.by(() => {
		const total = booking.total_amount ?? 0;
		const paid = (booking.payments ?? [])
			.filter((p) => p.status === 'completed' && p.payment_type !== 'tip')
			.reduce((sum, p) => sum + p.amount, 0);
		const refunded = (booking.refunds ?? [])
			.filter((r) => r.status === 'completed')
			.reduce((sum, r) => sum + r.amount, 0);
		return Math.max(0, Math.min(total - paid + refunded, total));
	});

	const remainingBalancePence = $derived(Math.round(remainingBalance * 100));

	// Overpayment confirmation. 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. This guard now applies both before AND after the appointment
	// has started (B12), so the prompt must fire in both states — it is keyed
	// purely off the backend's `overflow_tip_confirmation_required` error code,
	// never off booking state. It typically fires on STALE booking data
	// (multi-tab, admin-changed totals, refunds that reopened capacity) where
	// the user would otherwise be stuck with an unresolvable 400. On the guard
	// firing, the rejected request (amount, type, cached card tokens) is parked
	// here and a Confirm/Cancel prompt is shown; Confirm resends the SAME
	// request with the flag, Cancel returns to the amount-editing form.
	let overflowConfirm = $state<{
		amountPence: number;
		paymentType: string;
		overflowPence: number;
		// Actual amount the backend will charge. For deposits the backend
		// charges req.Amount minus the eligible campaign credit (the frontend
		// sends deposits RAW), so this can differ from amountPence.
		chargePence?: number;
		cardId?: string;
		newCardToken?: string;
		verificationToken?: string;
	} | null>(null);

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

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

	// Deposit policy warning text — dynamic based on booking state
	const expectedDepositPercent = $derived(booking.deposit_required ? 20 : 0);
	const depositPolicyWarning = $derived<string | null>(
		{
			get text(): string | null {
				if (!booking.deposit_required && totalPaid === 0 && booking.amount_due <= 0) return null;
				if (booking.deposit_required) {
					return `A ${expectedDepositPercent}% deposit (at least £${(booking.total_amount * 0.2).toFixed(2)}) is required. Any payments up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) are treated as deposit for cancellations.`;
				}
				if (totalPaid > 0 || booking.amount_due > 0) {
					return `Any payment up to 50% of total (£${(booking.total_amount * 0.5).toFixed(2)}) is treated as a protected deposit for cancellations. Paying early is at your own risk.`;
				}
				return null;
			}
		}.text
	);

	const isScenarioA = $derived(depositOutstanding);

	const isScenarioB = $derived(!depositOutstanding && totalPaid < booking.total_amount);

	const partialAmountNum = $derived(partialAmount === '' ? 0 : parseFloat(partialAmount));
	const partialAmountValid = $derived(
		paymentType === 'partial' &&
			partialAmount !== '' &&
			!isNaN(partialAmountNum) &&
			partialAmountNum > 0 &&
			partialAmountNum <= remainingBalance &&
			/^\d+(\.\d{0,2})?$/.test(partialAmount)
	);

	const partialValidationError = $derived(
		paymentType === 'partial' && !partialAmountValid
			? partialAmount === ''
				? 'Enter an amount'
				: !/^\d+(\.\d{0,2})?$/.test(partialAmount)
					? 'Invalid amount format'
					: partialAmountNum <= 0
						? 'Amount must be greater than 0'
						: partialAmountNum > remainingBalance
							? 'Amount exceeds balance'
							: 'Invalid amount'
			: null
	);

	const payButtonDisabled = $derived(
		status === 'processing' ||
			!cardSelectionValid ||
			(paymentType === 'partial' && !partialAmountValid) ||
			(booking.status === 'pending_release' && (lockTimer <= 0 || !lockAcquired))
	);

	function formatCurrency(pence: number): string {
		return new Intl.NumberFormat('en-GB', {
			style: 'currency',
			currency: 'GBP'
		}).format(pence / 100);
	}

	function formatTimer(seconds: number): string {
		const m = Math.floor(seconds / 60);
		const s = seconds % 60;
		return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
	}

	function clearLockIntervals() {
		if (countdownInterval) {
			clearInterval(countdownInterval);
			countdownInterval = null;
		}
		if (lockInterval) {
			clearInterval(lockInterval);
			lockInterval = null;
		}
	}

	async function acquireLock() {
		try {
			const response = await apiFetch(`/api/bookings/${booking.id}/payment-lock`, {
				method: 'POST'
			});
			if (response.ok) {
				lockAcquired = true;
				lockTimer = 300;
			}
		} catch (_err) {
			console.error('Failed to acquire payment lock:', _err);
		}
	}

	async function releaseLock() {
		lockAcquired = false;
		clearLockIntervals();
		try {
			await apiFetch(`/api/bookings/${booking.id}/payment-lock`, {
				method: 'DELETE'
			});
		} catch (_err) {
			console.error('Failed to release payment lock:', _err);
		}
	}

	function startCountdown() {
		countdownInterval = setInterval(() => {
			lockTimer = Math.max(0, lockTimer - 1);
		}, 1000);
	}

	function startRenewal() {
		lockInterval = setInterval(async () => {
			try {
				const response = await apiFetch(`/api/bookings/${booking.id}/payment-lock`, {
					method: 'POST'
				});
				if (response.ok) {
					lockTimer = 300;
					lockAcquired = true;
				}
			} catch (_err) {
				console.error('Failed to renew payment lock:', _err);
			}
		}, 60000);
	}

	// Cached payment methods come from the shared savedCards store (single
	// fetch of /api/user/payment-methods), so the account, booking and tip
	// surfaces can't drift on the API shape or the loading semantics.
	async function loadSavedCards() {
		if (!authStore.isAuthenticated) return;
		await savedCardsStore.fetch();
	}

	async function fetchLoyaltyData() {
		if (!authStore.isAuthenticated) return;
		try {
			const response = await apiFetch('/api/user/loyalty');
			if (response.ok) {
				const data = await response.json();
				stamps = data.stamps ?? 0;
			}
		} catch (_err) {
			console.error('Failed to fetch loyalty data:', _err);
		}
	}

	function handlePartialAmountInput(e: Event) {
		const input = e.target as HTMLInputElement;
		const sanitized = sanitizeDecimalInput(input.value);
		// Only update if the sanitized value passes the regex (max 2 decimal places)
		if (sanitized === '' || /^\d+(\.\d{0,2})?$/.test(sanitized)) {
			partialAmount = sanitized;
			lastValidPartialAmount = sanitized;
		} else {
			// Reject input with more than 2 decimal places — revert to last valid
			partialAmount = lastValidPartialAmount;
			input.value = lastValidPartialAmount;
		}
	}

	async function makePayment(paymentType: string, amountPence: number) {
		status = 'processing';
		error = null;

		// Apply loyalty redemption before payment
		if (useLoyalty) {
			try {
				const redemptionResponse = await apiFetch(`/api/bookings/${booking.id}/apply-redemption`, {
					method: 'POST',
					headers: { 'Content-Type': 'application/json' }
				});
				if (!redemptionResponse.ok) {
					const errData = await redemptionResponse.text();
					throw new Error(extractErrorMessage(errData) || 'Failed to apply loyalty discount');
				}
			} catch (_err) {
				status = 'error';
				const msg = _err instanceof Error ? _err.message : 'Failed to apply loyalty discount';
				error = msg;
				toast.error(msg);
				return;
			}
		}

		let cardId: string | undefined;
		let newCardToken: string | undefined;
		let verificationToken: string | undefined;

		// Saved-card charge: the card is used directly — B6/B10 requires the
		// customer's current 2FA verification code (collected in the charge form)
		// when the backend enforces the gate, but never blocks the selection.
		if (selectedCardId) {
			cardId = selectedCardId;
		} else if (cardSelection) {
			// New-card mode: tokenize once per attempt WITH SCA verification, then
			// reuse the cached nonce + verification token on retry (tokenization
			// is one-shot; the backend idempotency key dedups). The verification
			// token is amount-bound, so a changed amount forces a fresh
			// tokenization.
			if (
				!newCardNonce ||
				newCardTokenizedForSaveCard !== saveCard ||
				isNonceStale(newCardTokenizedAt, newCardTokenAmount, amountPence)
			) {
				try {
					const tokenized = await cardSelection.tokenizeWithVerification(
						amountPence,
						{
							givenName: authStore.currentUser?.firstName,
							familyName: authStore.currentUser?.lastName,
							email: authStore.currentUser?.email
						},
						saveCard
					);
					newCardNonce = tokenized.nonce;
					newCardVerificationToken = tokenized.verificationToken ?? '';
					newCardTokenAmount = amountPence;
					newCardTokenizedAt = Date.now();
					newCardTokenizedForSaveCard = saveCard;
					// This attempt carries SCA verification — a prior
					// 'sca-unavailable' demotion must not leak onto it (the 2FA
					// input only surfaces while SCA genuinely cannot authorise).
					lastSCAOutcome = 'verified';
				} catch (_err) {
					status = 'error';
					const msg = _err instanceof Error ? _err.message : 'Card entry failed';
					error = msg;
					toast.error(msg);
					return;
				}
			}
			newCardToken = newCardNonce;
			verificationToken = newCardVerificationToken || undefined;
		} else {
			status = 'error';
			error = 'Please select a payment method';
			toast.error('Please select a payment method');
			return;
		}

		// Cache the idempotency key per amount+type+card so a lost-response
		// retry reuses it (backend dedups) instead of double-charging. The
		// new-card identity is a STABLE sentinel, NOT the cnon: nonce: the
		// nonce is one-shot and cleared on a failed charge, so keying on it
		// would regenerate the key on retry and a network-timeout retry (where
		// the charge actually landed) would double-charge. The nonce changes
		// per tokenize but represents the same logical card intent.
		const cardKey = cardId ?? 'new-card';
		if (
			!payIdempotencyKey ||
			payKeyedAmount !== amountPence ||
			payKeyedType !== paymentType ||
			payKeyedCard !== cardKey
		) {
			payIdempotencyKey = generateUUID();
			payKeyedAmount = amountPence;
			payKeyedType = paymentType;
			payKeyedCard = cardKey;
		}

		// Proactive saved-card (ccof) SCA: run the client-side challenge BEFORE
		// the first charge attempt so it 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 user taps Pay again to re-run it, and
		// the cached idempotency key above is never regenerated across the
		// challenge-then-charge. waitingForSCA drives the "approve in your
		// banking app" panel and blocks modal close while the challenge is open.
		if (cardId && !verificationToken) {
			waitingForSCA = true;
			try {
				const proactive = await runSavedCardSCAProactively(amountPence, cardId);
				if (proactive.outcome === 'challenge-cancelled' || proactive.outcome === 'sca-failed') {
					status = 'error';
					error = CARD_VERIFICATION_RETRY_MESSAGE;
					toast.error(error);
					return;
				}
				if (proactive.verificationToken) verificationToken = proactive.verificationToken;
			} finally {
				waitingForSCA = false;
			}
		}

		await submitBookingPayment(
			paymentType,
			amountPence,
			cardId,
			newCardToken,
			verificationToken,
			false
		);
	}

	// Submits a booking-payment request and processes the outcome. Shared by
	// the initial attempt and the overflow-tip confirm resend so both use the
	// exact same success/error handling. `confirmOverflowTip` adds the backend's
	// opt-in flag for a pre-start overpayment; the resend reuses the SAME
	// cached nonce/verification token/idempotency key as the rejected attempt
	// (the guard fired before any Square call, so the tokens are unconsumed and
	// the key is still the correct dedup identity for this amount+type+card).
	async function submitBookingPayment(
		paymentType: string,
		amountPence: number,
		cardId: string | undefined,
		newCardToken: string | undefined,
		verificationToken: string | undefined,
		confirmOverflowTip: boolean
	): Promise<void> {
		let responseStatus = 0;
		try {
			const response = await submitPaymentWithRetry(() =>
				apiFetch(`/api/bookings/${booking.id}/payment`, {
					method: 'POST',
					headers: { 'Content-Type': 'application/json' },
					body: JSON.stringify({
						amount: amountPence,
						payment_type: paymentType,
						...(confirmOverflowTip ? { confirm_overflow_tip: true } : {}),
						...(cardId ? { card_id: cardId } : {}),
						...(newCardToken ? { new_card_token: newCardToken, save_card: saveCard } : {}),
						...(verificationToken ? { verification_token: verificationToken } : {}),
						...(twoFactor.showInput ? { verification_code: twoFactor.code } : {}),
						idempotency_key: payIdempotencyKey
					})
				})
			);

			if (!response.ok) {
				responseStatus = response.status;
				const errData = await response.text();
				// Pre-start overpayment on stale booking data: park the rejected
				// request (amount, type, cached tokens) and surface the
				// Confirm/Cancel prompt instead of a dead-end 400. The cached
				// nonce + SCA verification token + idempotency key are NOT
				// cleared here — the confirm resend is the same logical charge.
				if (!confirmOverflowTip && isOverflowTipConfirmationRequired(errData)) {
					// The backend's overflow guard compares against the DISCOUNTED
					// remaining (remaining + eligible campaign credit), and for a
					// DEPOSIT it charges req.Amount − the campaign credit (the
					// frontend sends deposits raw). Both the displayed overflow
					// and the amount actually charged must therefore account for
					// the eligible campaign discount on the deposit path.
					const depositDiscountPence =
						paymentType === 'deposit' ? campaignDiscountPence(discountPreview) : 0;
					overflowConfirm = {
						amountPence,
						paymentType,
						overflowPence: Math.max(0, amountPence - remainingBalancePence - depositDiscountPence),
						chargePence: Math.max(0, amountPence - depositDiscountPence),
						cardId,
						newCardToken,
						verificationToken
					};
					status = 'idle';
					return;
				}
				// Defensive fallback: a `verification_required` 402 on a
				// saved-card charge should no longer happen — the first attempt
				// always carried a proactive verification_token or demoted to
				// 2FA (sca-unavailable). If it still occurs (e.g. a stale token
				// got consumed/expired between tokenize and charge), surface the
				// guidance and let the user retry — never re-run SCA silently
				// mid-flow. The catch below maps this signal to
				// VERIFICATION_REQUIRED_MESSAGE.
				const err = new Error(extractErrorMessage(errData) || 'Failed to initiate payment');
				(err as { bodyText?: string }).bodyText = errData;
				throw err;
			}

			const data = await response.json();
			// Payment is synchronous (completed immediately)
			status = 'success';
			overflowConfirm = null;
			payIdempotencyKey = '';
			payKeyedAmount = 0;
			payKeyedType = '';
			payKeyedCard = '';
			newCardNonce = '';
			newCardVerificationToken = '';
			newCardTokenAmount = 0;
			newCardTokenizedAt = 0;
			newCardTokenizedForSaveCard = false;
			twoFactor.setCode('');
			twoFactor.reveal = false;
			// The backend skips the Square charge entirely when an eligible
			// campaign discount covers the whole deposit
			// (`deposit_covered_by_discount` — a £0 charge is invalid at
			// Square). Report it as a completed, nothing-to-pay deposit.
			paymentResult = {
				id: data.id ?? '',
				amount: data.amount ?? 0,
				card_brand: data.card_brand,
				card_last4: data.card_last4,
				payment_type: data.payment_type ?? 'deposit'
			};
			toast.success(
				data.deposit_covered_by_discount
					? 'Deposit covered by your discount — nothing to pay'
					: 'Payment successful'
			);
			savedCardsStore.invalidate();
			onComplete();
			releaseLock();
		} catch (_err) {
			status = 'error';
			overflowConfirm = null;
			let msg = _err instanceof Error ? _err.message : 'Payment declined';
			// A 402 with 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".
			const bodyText = (_err as { bodyText?: string })?.bodyText ?? '';
			const verificationFailure = isVerificationRequiredSignal(responseStatus, bodyText);
			if (verificationFailure) {
				msg = VERIFICATION_REQUIRED_MESSAGE;
			}
			// B6/B10: a 2FA verification-gate rejection (missing/invalid/expired
			// code, brute-force lockout) is recoverable — keep the code populated
			// and reveal the input so the charge can be retried with a fresh code.
			if (isTwoFactorVerificationGateFailure(responseStatus, msg)) twoFactor.reveal = true;
			error = msg;
			toast.error(verificationFailure ? msg : `${msg}. Please try again or use another card.`);
			// A definitive charge failure consumes the nonce + SCA verification
			// token — clear the cached pair so retries re-tokenize fresh. The
			// idempotency key stays for network-timeout dedup. CardSelection stays
			// mounted on error, so this also stops a user who corrects their card
			// digits from resubmitting the old nonce for the new card.
			newCardNonce = '';
			newCardVerificationToken = '';
			newCardTokenAmount = 0;
			newCardTokenizedAt = 0;
			newCardTokenizedForSaveCard = false;
			// 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 (the key's purpose
			// is dedup on AMBIGUOUS 503 retries, where the charge may have
			// landed). Regenerate the key on 402 so the next Pay click gets a
			// fresh key + fresh pending row. Keep it on 503/network (ambiguous)
			// and on challenge-cancelled/sca-failed (no charge was attempted).
			if (responseStatus === 402) {
				payIdempotencyKey = '';
				payKeyedAmount = 0;
				payKeyedType = '';
				payKeyedCard = '';
			}
			releaseLock();
		}
	}

	/**
	 * Saved-card (ccof) SCA challenge, run PROACTIVELY before the first charge
	 * attempt (never after a 402). Square's card.tokenize(verificationDetails,
	 * squareCardId) determines UP FRONT whether buyer verification is required
	 * and returns a fresh verification_token bound to the exact amount:
	 *  - 'verified' → the caller charges with the returned token (the first
	 *    attempt carries it — a naked ccof is never sent);
	 *  - 'sca-unavailable' → SCA can't run; the 2FA gate is demoted from backup
	 *    to the only available gate and the caller proceeds WITHOUT a token;
	 *  - 'challenge-cancelled' / 'sca-failed' → the caller must NOT charge:
	 *    the pending row stays retryable and the user taps Pay again to re-run
	 *    the challenge.
	 */
	async function runSavedCardSCAProactively(
		amountPence: number,
		cardId: string
	): Promise<{ outcome: SavedCardVerificationOutcome; verificationToken?: string }> {
		const squareCardId = savedCardsStore.cards.find((c) => c.id === cardId)?.square_card_id;
		if (!squareCardId) {
			lastSCAOutcome = 'sca-unavailable';
			return { outcome: 'sca-unavailable' };
		}
		let result: SavedCardVerificationResult;
		try {
			result = await tokenizeSavedCardWithVerification(amountPence, squareCardId, {
				givenName: authStore.currentUser?.firstName,
				familyName: authStore.currentUser?.lastName,
				email: authStore.currentUser?.email
			});
		} catch (_err) {
			lastSCAOutcome = 'sca-unavailable';
			return { outcome: 'sca-unavailable' };
		}
		lastSCAOutcome = result.outcome;
		return { outcome: result.outcome, verificationToken: result.verificationToken ?? undefined };
	}

	// Confirm the overpayment: resend the SAME rejected request with
	// confirm_overflow_tip: true so the excess is recorded as a tip. Works for
	// both pre-start and post-start overflows (B12).
	async function confirmOverflowPayment() {
		const pending = overflowConfirm;
		if (!pending || status === 'processing') return;
		status = 'processing';
		error = null;
		await submitBookingPayment(
			pending.paymentType,
			pending.amountPence,
			pending.cardId,
			pending.newCardToken,
			pending.verificationToken,
			true
		);
	}

	// Revert to the amount-editing form. The cached nonce/tokens/idempotency key
	// stay: a resubmit with the SAME amount+type+card reuses them (no charge was
	// made — the guard fired before Square), and a changed amount forces a fresh
	// tokenization + key.
	function cancelOverflowConfirmation() {
		overflowConfirm = null;
		status = 'idle';
	}

	function handlePayDeposit() {
		const depositPence = booking.deposit_amount
			? Math.round(booking.deposit_amount * 100)
			: Math.round(booking.total_amount * 0.2 * 100);
		makePayment('deposit', depositPence);
	}

	function handlePayFull() {
		const fullPence = Math.round(booking.amount_due * 100);
		const discountedPence = Math.max(
			0,
			fullPence - campaignDiscountPence(discountPreview) - loyaltyDiscount
		);
		const paymentType = booking.amount_paid > 0 ? 'balance' : 'full';
		makePayment(paymentType, discountedPence);
	}

	function handlePayPartial() {
		if (!partialAmountValid) {
			toast.error('Please enter a valid amount');
			return;
		}
		makePayment('partial', Math.round(partialAmountNum * 100));
	}

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

	// Fetch payment methods + loyalty on mount if authenticated
	$effect(() => {
		if (authStore.isAuthenticated) {
			loadSavedCards();
			fetchLoyaltyData();
		}
	});

	// Cleanup on unmount
	$effect(() => {
		return () => {
			if (countdownInterval) clearInterval(countdownInterval);
			if (lockInterval) clearInterval(lockInterval);
		};
	});

	onMount(async () => {
		if (booking.status === 'pending_release') {
			acquireLock();
			startCountdown();
			startRenewal();
		}

		// Fetch eligible campaign discounts
		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);
		}
	});

	onDestroy(() => {
		if (booking.status === 'pending_release' && lockAcquired) {
			releaseLock();
		}
	});
</script>

<Dialog.Root
	open={true}
	onOpenChange={(open) => {
		if (open) return;
		// ESC/overlay while a payment is in flight (the charge, or the
		// out-of-band SCA challenge the buyer must approve in their banking
		// app) must NOT close the modal — the charge may still land, and
		// closing mid-challenge strands it. Block close until it resolves.
		if (status === 'processing' || waitingForSCA) return;
		// ESC while the overflow-confirm prompt is showing must dismiss the
		// prompt (back to the amount-editing form) instead of closing the whole
		// modal — the payment was rejected by the guard and the user needs to
		// confirm or adjust, not lose the flow entirely.
		if (overflowConfirm) {
			cancelOverflowConfirmation();
			return;
		}
		handleClose();
	}}
>
	<Dialog.Content class="max-w-[calc(100%-2rem)] sm:max-w-md">
		<Dialog.Header>
			<Dialog.Title class="text-xl font-semibold">Make a Payment</Dialog.Title>
			{#if booking.id}
				<div class="mt-1 text-sm text-gray-500">Booking ID: {booking.id}</div>
			{/if}
		</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 markup with the
				 booking-flow deposit step (OverflowTipConfirm) so the two surfaces
				 can't drift. -->
				<OverflowTipConfirm
					overflowPence={overflowConfirm.overflowPence}
					discountNote={overflowConfirm.paymentType === 'deposit' &&
					overflowConfirm.chargePence !== undefined
						? `An eligible campaign discount of ${formatCurrency(
								Math.max(0, overflowConfirm.amountPence - overflowConfirm.chargePence)
							)} applies — you'll be charged ${formatCurrency(overflowConfirm.chargePence)}.`
						: undefined}
					loading={status === 'processing'}
					onConfirm={confirmOverflowPayment}
					onCancel={cancelOverflowConfirmation}
				/>
				<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
				<Button
					variant="ghost"
					onclick={handleClose}
					class="w-full"
					disabled={status === 'processing'}
				>
					Close
				</Button>
			</div>
		{:else if status === 'idle' || status === 'processing' || status === 'error'}
			<div class="space-y-4">
				<!-- Payment lock countdown banner — only for pending_release (vulnerable slot) -->
				{#if booking.status === 'pending_release' && lockAcquired && lockTimer > 0}
					<div
						class="flex items-center gap-2 rounded-md border p-3 text-sm {lockTimer <= 60
							? 'border-amber-200 bg-amber-50 text-amber-800'
							: 'border-blue-200 bg-blue-50 text-blue-800'}"
					>
						<svg
							class="h-4 w-4 shrink-0"
							viewBox="0 0 24 24"
							fill="none"
							stroke="currentColor"
							stroke-width="2"
						>
							<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
							<path d="M7 11V7a5 5 0 0110 0v4" />
						</svg>
						<span
							>Slot re-secured for <strong>{formatTimer(lockTimer)}</strong> to ensure smooth payment
							processing</span
						>
					</div>
				{:else if booking.status === 'pending_release' && (lockTimer === 0 || !lockAcquired)}
					<div
						class="flex items-center gap-2 rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800"
					>
						<svg
							class="h-4 w-4 shrink-0"
							viewBox="0 0 24 24"
							fill="none"
							stroke="currentColor"
							stroke-width="2"
						>
							<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
							<path d="M7 11V7a5 5 0 0110 0v4" />
						</svg>
						<span>Slot no longer secured — please close and retry</span>
					</div>
				{/if}
				<!-- Out-of-band SCA challenge: the buyer approves in their banking
				     app while this panel shows. -->
				{#if waitingForSCA}
					<div class="rounded-md border border-amber-200 bg-amber-50 p-4">
						<div class="flex items-center gap-3">
							<div
								class="h-5 w-5 shrink-0 animate-spin rounded-full border-2 border-amber-400 border-t-transparent"
							></div>
							<div>
								<p class="text-sm font-medium text-amber-900">
									Approve this payment in your banking app on your phone…
								</p>
								<p class="mt-0.5 text-xs text-amber-700">
									The payment is waiting for your approval. This may take a few moments.
								</p>
							</div>
						</div>
					</div>
				{/if}
				<!-- Service Breakdown -->
				<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="space-y-2">
						{#each booking.services ?? [] as service, index (index)}
							<div class="flex justify-between text-sm">
								<span class="text-gray-600">{service.service_name || 'Unknown Service'}</span>
								<span class="font-medium">
									{service.override_price
										? formatCurrency(Math.round(service.override_price * 100))
										: service.price
											? formatCurrency(Math.round(service.price * 100))
											: '-'}
								</span>
							</div>
						{/each}
					</div>
				</div>

				<!-- Loyalty Redemption Checkbox -->
				{#if loyaltyEligible}
					<div class="rounded-md border border-fuchsia-100 bg-fuchsia-50 p-4">
						<div class="mb-2 text-sm font-semibold text-fuchsia-800">Available Savings</div>
						<div class="flex items-start gap-3">
							<Checkbox
								id="use-loyalty"
								bind:checked={useLoyalty}
								disabled={status === 'processing'}
							/>
							<label for="use-loyalty" class="cursor-pointer select-none">
								<div class="text-sm font-medium text-fuchsia-900">Use my Loyalty Stamp Card</div>
								<div class="mt-0.5 text-xs text-fuchsia-700">
									{stamps} stamps available &middot; {Math.round(LOYALTY_DISCOUNT_RATE * 100)}% off
									({formatCurrency(Math.round(booking.total_amount * 100 * LOYALTY_DISCOUNT_RATE))})
								</div>
							</label>
						</div>
					</div>
				{/if}

				<!-- Applied Discounts -->
				{#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>
							{#if servicesSubtotal > 0}
								<span
									class="rounded-full bg-fuchsia-50 px-2 py-0.5 text-xs font-medium text-fuchsia-600"
								>
									{((discountSum / servicesSubtotal) * 100).toFixed(0)}% Off Total
								</span>
							{/if}
						</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}

				<!-- Financial Summary -->
				<div class="space-y-2 rounded-md border border-gray-200 bg-white p-4">
					<div class="flex justify-between text-sm">
						<span class="text-gray-600">Total</span>
						<span class="font-medium">{formatCurrency(Math.round(booking.total_amount * 100))}</span
						>
					</div>
					{#if discountPreview?.eligible}
						{#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))}</span
								>
							</div>
						{/each}
					{/if}
					<div class="flex justify-between text-sm">
						<span class="text-gray-600">Amount Paid</span>
						<span class="font-medium text-green-700">{formatCurrency(totalPaid)}</span>
					</div>
					{#if useLoyalty && loyaltyDiscount > 0}
						<div class="flex justify-between text-sm">
							<span class="text-gray-600">Loyalty Stamp Card (10% Off)</span>
							<span class="font-medium text-green-700">-{formatCurrency(loyaltyDiscount)}</span>
						</div>
					{/if}
					<div class="flex justify-between border-t border-gray-200 pt-2">
						<span class="font-semibold text-gray-900">Amount Remaining</span>
						<span class="text-lg font-bold text-red-600">
							{formatCurrency(
								Math.max(
									0,
									remainingBalancePence - campaignDiscountPence(discountPreview) - loyaltyDiscount
								)
							)}
						</span>
					</div>
				</div>

				<!-- Card Selection. Mounted for idle/error so a declined card can be
						retried/swapped without closing the modal, AND for processing:
						makePayment() sets status='processing' FIRST, then awaits the
						loyalty redemption and tokenizeWithVerification. Svelte 5 flushes
						on the next microtask after the await yields, so a status gate
						that unmounts CardSelection mid-payment would null the bind:this
						ref AND destroy the Square iframe mid-tokenization, making the
						new-card path fail with "Please select a payment method" /
						"Card entry failed". Staying mounted keeps both alive for the
						full duration of makePayment. -->
				{#if authStore.isAuthenticated}
					{#if savedCardsStore.loading}
						<div class="py-2 text-center text-sm text-gray-500">Loading payment methods...</div>
					{:else}
						<CardSelection
							bind:this={cardSelection}
							cards={savedCardsStore.cards}
							{canSaveCards}
							bind:selectedCardId
							bind:saveCard
							onValidityChange={(v) => (cardSelectionValid = v)}
						/>
					{/if}
				{/if}

				<!-- B6/B10: saved-card charges require the customer's current 2FA
					 verification code when the backend enforces the gate. -->
				<TwoFactorCodeInput
					bind:code={twoFactor.code}
					showInput={twoFactor.showInput}
					enabled={twoFactorEnabled}
				/>
				{#if twoFactor.showInput && twoFactorEnabled}
					<Button
						variant="outline"
						size="sm"
						class="w-full"
						loading={twoFactor.requesting}
						disabled={twoFactor.requesting}
						onclick={twoFactor.requestNewCode}
					>
						Request a new code
					</Button>
				{/if}

				{#if depositPolicyWarning}
					<div class="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
						<p class="font-semibold text-amber-900">Cancellation &amp; Deposit Policy</p>
						<p class="mt-1">{depositPolicyWarning}</p>
						<PolicyPopover>
							{#snippet trigger()}
								<span class="mt-1 inline-block underline">Full cancellation policy →</span>
							{/snippet}
						</PolicyPopover>
					</div>
				{/if}

				<!-- Scenario A: Deposit needed -->
				{#if isScenarioA}
					<div class="space-y-3">
						<!-- Payment type radio buttons -->
						<div class="grid grid-cols-2 gap-3">
							<button
								type="button"
								class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
								'deposit'
									? 'border-input bg-fuchsia-100 text-foreground'
									: 'border-input hover:bg-fuchsia-50'}"
								onclick={() => (paymentType = 'deposit')}
							>
								Pay Deposit
							</button>
							<button
								type="button"
								class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
								'full'
									? 'border-input bg-fuchsia-100 text-foreground'
									: 'border-input hover:bg-fuchsia-50'}"
								onclick={() => (paymentType = 'full')}
							>
								Pay in Full
							</button>
						</div>

						<!-- Pay button -->
						<Button
							onclick={() => (paymentType === 'deposit' ? handlePayDeposit() : handlePayFull())}
							class="w-full"
							loading={status === 'processing'}
							disabled={payButtonDisabled || twoFactor.missing}
						>
							{#if paymentType === 'deposit'}
								Pay Deposit ({formatCurrency(
									depositChargePence(
										booking.deposit_amount
											? Math.round(booking.deposit_amount * 100)
											: Math.round(booking.total_amount * 0.2 * 100),
										campaignDiscountPence(discountPreview)
									)
								)})
							{:else}
								Pay {formatCurrency(
									Math.max(
										0,
										Math.round(booking.amount_due * 100) -
											campaignDiscountPence(discountPreview) -
											(useLoyalty ? loyaltyDiscount : 0)
									)
								)}
							{/if}
						</Button>
						<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
					</div>
				{/if}

				<!-- Scenario B: Partial or full payment -->
				{#if isScenarioB}
					<div class="space-y-3">
						<!-- Payment type radio buttons -->
						<div class="grid grid-cols-2 gap-3">
							<button
								type="button"
								class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
								'full'
									? 'border-input bg-fuchsia-100 text-foreground'
									: 'border-input hover:bg-fuchsia-50'}"
								onclick={() => (paymentType = 'full')}
							>
								Pay in Full
							</button>
							<button
								type="button"
								class="rounded-lg border py-3 text-center text-sm font-semibold transition-colors {paymentType ===
								'partial'
									? 'border-input bg-fuchsia-100 text-foreground'
									: 'border-input hover:bg-fuchsia-50'}"
								onclick={() => (paymentType = 'partial')}
							>
								Pay Part
							</button>
						</div>

						<!-- Partial amount input (only when partial selected) -->
						{#if paymentType === 'partial'}
							<div>
								<label for="partial-amount" class="text-sm font-medium text-gray-700">
									Partial Payment Amount
								</label>
								<div class="relative mt-1">
									<span class="absolute top-1/2 left-3 -translate-y-1/2 text-gray-500">£</span>
									<Input
										id="partial-amount"
										type="text"
										inputmode="decimal"
										step="0.01"
										placeholder="0.00"
										value={partialAmount}
										oninput={handlePartialAmountInput}
										class="pl-7"
										disabled={status !== 'idle'}
									/>
								</div>
								{#if partialValidationError}
									<p class="mt-1 text-sm text-red-600">{partialValidationError}</p>
								{/if}
							</div>
						{/if}

						<!-- Pay button -->
						<Button
							onclick={() => (paymentType === 'partial' ? handlePayPartial() : handlePayFull())}
							class="w-full"
							loading={status === 'processing'}
							disabled={payButtonDisabled || twoFactor.missing}
						>
							{#if paymentType === 'partial'}
								Pay {partialAmountValid
									? formatCurrency(Math.round(partialAmountNum * 100))
									: 'Part'}
							{:else}
								Pay {formatCurrency(
									Math.max(
										0,
										Math.round(booking.amount_due * 100) -
											campaignDiscountPence(discountPreview) -
											(useLoyalty ? loyaltyDiscount : 0)
									)
								)}
							{/if}
						</Button>
						<p class="mt-4 text-center text-xs text-gray-500">Secure payment powered by Square</p>
					</div>
				{/if}

				{#if status === 'error' && error}
					<div class="rounded-md border border-red-200 bg-red-50 p-3">
						<p class="text-sm text-red-800">{error}</p>
						<Button
							variant="outline"
							size="sm"
							class="mt-3 w-full"
							onclick={() => {
								status = 'idle';
								error = null;
							}}
						>
							Try Again
						</Button>
					</div>
				{/if}

				<!-- Close button -->
				<Button
					variant="ghost"
					onclick={handleClose}
					class="w-full"
					disabled={status === 'processing'}
				>
					Close
				</Button>
			</div>
		{:else if status === 'success' && paymentResult}
			<!-- Success State -->
			<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>

				<!-- Receipt -->
				<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>
						<div class="flex justify-between">
							<span class="text-sm text-gray-600">Type</span>
							<span class="font-medium text-gray-900 capitalize">
								{paymentResult.payment_type}
							</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.card_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={handleClose} class="w-full">Done</Button>
			</div>
		{/if}
	</Dialog.Content>
</Dialog.Root>
