/* global wp, turnstile, jQuery, wc, turnstileWooCheckoutCallback */
/**
 * WooCommerce checkout Turnstile lifecycle.
 *
 * The checkout container is rendered without the `cf-turnstile` class, so api.js never
 * claims it implicitly and this file is the single owner of its lifecycle:
 *
 * - Classic: re-renders after every updated_checkout fragment swap (the widget markup lives
 *   inside the replaced review-order fragment) and resets after a failed order.
 * - Blocks: forms are never POSTed, so the token is pushed into Store API extension data.
 *   The client never blocks place-order on its own — the server-side Store API validator
 *   is the single authority — so a blocked or cache-stale Turnstile script can never
 *   dead-end checkout.
 *
 * Turnstile tokens are single-use and expire, so a token is never cached across a
 * place-order attempt: every attempt reads live state and resets the widget afterwards.
 */
(function () {
	var WIDGET_ID = 'cf-turnstile-woo-checkout';
	var WIDGET_ATTR = 'data-wpps-widget-id';
	var REVEALED_ATTR = 'data-wpps-revealed';
	var lastPushedToken = '';
	var tokenWaiters = [];
	var submitting = false;
	var lastSubmitHadToken = true;
	// Tracked outside the DOM: a fragment swap or React re-render replaces the container,
	// and the widget id is the only handle left for cleaning up the orphaned instance.
	var activeWidgetId = '';

	function ns() {
		return typeof wpPsTurnstileWoo !== 'undefined' && wpPsTurnstileWoo.ns
			? wpPsTurnstileWoo.ns
			: 'wp-powersuite-turnstile';
	}

	function cfg() {
		return typeof wpPsTurnstileWoo !== 'undefined' ? wpPsTurnstileWoo : {};
	}

	function hasTurnstileApi() {
		return typeof turnstile !== 'undefined' && typeof turnstile.render === 'function';
	}

	function widgetEl() {
		return document.getElementById(WIDGET_ID);
	}

	/**
	 * Prefer the SSR host printed before Place Order (checkout-actions-block).
	 * Never insert into `.wc-block-checkout__actions_row` — that throws React #60.
	 * If the SSR host was wiped, place a new host as a sibling *before*
	 * `.wc-block-checkout__actions` (still outside the React-owned row).
	 */
	function ensureWidgetEl() {
		var el = widgetEl();
		if (el) {
			return el;
		}
		if (!cfg().siteKey) {
			return null;
		}

		var host = document.getElementById('wpps-turnstile-blocks-host');
		if (!host) {
			var actions =
				document.querySelector('.wc-block-checkout__actions') ||
				document.querySelector('.wp-block-woocommerce-checkout-actions-block');
			var checkoutRoot = document.querySelector('.wp-block-woocommerce-checkout');
			var parent = actions && actions.parentNode
				? actions.parentNode
				: checkoutRoot && checkoutRoot.parentNode
					? checkoutRoot.parentNode
					: null;
			if (!parent) {
				return null;
			}
			host = document.createElement('div');
			host.id = 'wpps-turnstile-blocks-host';
			host.className = 'wpps-captcha-cushion';
			host.style.clear = 'both';
			host.style.margin = '16px 0';
			// Before Place Order section when possible; never inside actions_row.
			if (actions && actions.parentNode === parent) {
				parent.insertBefore(host, actions);
			} else if (checkoutRoot && checkoutRoot.parentNode === parent) {
				parent.insertBefore(host, checkoutRoot);
			} else {
				parent.appendChild(host);
			}
		}

		if (widgetEl()) {
			return widgetEl();
		}

		var embed = document.createElement('div');
		embed.className = 'wpps-turnstile-embed';

		el = document.createElement('div');
		el.id = WIDGET_ID;
		el.className = 'wpps-turnstile-widget wpps-turnstile-explicit';

		embed.appendChild(el);
		host.appendChild(embed);

		return el;
	}

	function pushExtensionData(token) {
		token = token || '';
		// Dispatching wakes every wp.data subscriber, including ours — skip no-op writes so
		// a failed render cannot ping-pong with the store watchers.
		if (token === lastPushedToken) {
			return;
		}
		lastPushedToken = token;
		if (typeof wp === 'undefined' || !wp.data || typeof wp.data.dispatch !== 'function') {
			return;
		}
		var dispatch = wp.data.dispatch('wc/store/checkout');
		if (!dispatch) {
			return;
		}
		var payload = { token: token || '' };
		if (typeof dispatch.setExtensionData === 'function') {
			dispatch.setExtensionData(ns(), payload);
		} else if (typeof dispatch.__internalSetExtensionData === 'function') {
			dispatch.__internalSetExtensionData(ns(), payload);
		}
	}

	function notifyTokenWaiters(token) {
		if (!tokenWaiters.length) {
			return;
		}
		var list = tokenWaiters.slice();
		tokenWaiters = [];
		list.forEach(function (fn) {
			try {
				fn(token || '');
			} catch (e) {}
		});
	}

	function unlockSubmit() {
		if (typeof turnstileWooCheckoutCallback === 'function') {
			try {
				turnstileWooCheckoutCallback();
			} catch (e) {}
		}
	}

	/**
	 * Live token for the current widget instance. Never returns a cached or expired token:
	 * a redeemed token fails siteverify with timeout-or-duplicate, which is what made
	 * "I already verified" retries keep failing.
	 */
	function currentToken() {
		var el = widgetEl();
		if (!el || !hasTurnstileApi()) {
			return '';
		}
		var wid = el.getAttribute(WIDGET_ATTR);
		if (!wid || wid !== activeWidgetId) {
			return '';
		}
		try {
			if (typeof turnstile.isExpired === 'function' && turnstile.isExpired(wid)) {
				return '';
			}
		} catch (e) {}
		try {
			var token = turnstile.getResponse(wid);
			return token ? String(token) : '';
		} catch (e2) {
			return '';
		}
	}

	function syncTokenToStore() {
		var token = currentToken();
		if (token && token !== lastPushedToken) {
			pushExtensionData(token);
		}
		return token;
	}

	function renderOpts(el, appearanceOverride) {
		var c = cfg();
		var sitekey = el.getAttribute('data-sitekey') || c.siteKey || '';
		if (!sitekey) {
			return null;
		}
		var opts = {
			sitekey: sitekey,
			action: el.getAttribute('data-action') || c.action || 'woo_checkout',
			theme: el.getAttribute('data-theme') || c.theme || 'auto',
			size: el.getAttribute('data-size') || c.apiSize || 'normal',
			appearance: appearanceOverride || el.getAttribute('data-appearance') || c.appearance || 'always',
			retry: 'auto',
			'refresh-expired': 'auto',
			callback: function (token) {
				pushExtensionData(token || '');
				unlockSubmit();
				notifyTokenWaiters(token || '');
			},
			'expired-callback': function () {
				pushExtensionData('');
			},
			'error-callback': function () {
				pushExtensionData('');
				notifyTokenWaiters('');
			},
		};
		var lang = el.getAttribute('data-language') || c.language || '';
		if (lang) {
			opts.language = lang;
		}
		var refreshTimeout = el.getAttribute('data-refresh-timeout') || c.refreshTimeout || '';
		if (refreshTimeout) {
			opts['refresh-timeout'] = refreshTimeout;
		}
		return opts;
	}

	/**
	 * Tear down the live widget by id. Leaving it behind makes Turnstile keep retrying and
	 * refreshing a detached instance, which is what logs "Cannot fire widget of chl-widget-…,
	 * consider using turnstile.remove()". Removing by id (never by container) also avoids
	 * "Nothing to remove found for the provided container" once the node is gone.
	 */
	function removeActiveWidget() {
		if (activeWidgetId && hasTurnstileApi() && typeof turnstile.remove === 'function') {
			try {
				turnstile.remove(activeWidgetId);
			} catch (e) {}
		}
		activeWidgetId = '';
		var el = widgetEl();
		if (el) {
			el.removeAttribute(WIDGET_ATTR);
		}
	}

	/**
	 * @param {boolean} force              Re-render even when a widget already exists.
	 * @param {string}  appearanceOverride Force an appearance mode (used to reveal a hidden challenge).
	 */
	function renderWidget(force, appearanceOverride) {
		if (!hasTurnstileApi()) {
			return false;
		}
		var el = ensureWidgetEl();
		if (!el) {
			return false;
		}
		if (!force && !appearanceOverride && activeWidgetId && el.getAttribute(WIDGET_ATTR) === activeWidgetId) {
			return true;
		}
		var opts = renderOpts(el, appearanceOverride);
		if (!opts) {
			return false;
		}

		removeActiveWidget();
		el.innerHTML = '';
		pushExtensionData('');

		try {
			var id = turnstile.render(el, opts);
			if (id === undefined || id === null || id === '') {
				return false;
			}
			activeWidgetId = String(id);
			el.setAttribute(WIDGET_ATTR, activeWidgetId);
			if (appearanceOverride) {
				el.setAttribute('data-appearance', appearanceOverride);
				el.setAttribute(REVEALED_ATTR, 'true');
			}
			return true;
		} catch (e) {
			return false;
		}
	}

	/** Discard the current token so the next attempt gets a fresh, unredeemed one. */
	function resetWidget() {
		var el = widgetEl();
		if (!el || !hasTurnstileApi()) {
			return;
		}
		pushExtensionData('');
		// Only reset the instance that still owns this node; a stale id belongs to a
		// container Turnstile can no longer find, so re-render instead.
		if (activeWidgetId && el.getAttribute(WIDGET_ATTR) === activeWidgetId && typeof turnstile.reset === 'function') {
			try {
				turnstile.reset(activeWidgetId);
				return;
			} catch (e) {}
		}
		renderWidget(true);
	}

	function whenTurnstileReady(onReady, onTimeout) {
		if (hasTurnstileApi()) {
			onReady();
			return;
		}
		var attempts = 0;
		var timer = setInterval(function () {
			attempts += 1;
			if (hasTurnstileApi()) {
				clearInterval(timer);
				onReady();
			} else if (attempts >= 100) {
				clearInterval(timer);
				if (typeof onTimeout === 'function') {
					onTimeout();
				}
			}
		}, 100);
	}

	/**
	 * interaction-only / managed widgets solve in the background a moment after render,
	 * so a place-order click can legitimately arrive before the token exists.
	 */
	function waitForToken(timeoutMs) {
		return new Promise(function (resolve) {
			var existing = syncTokenToStore();
			if (existing) {
				resolve(existing);
				return;
			}

			var settled = false;
			var started = Date.now();

			function finish(token) {
				if (settled) {
					return;
				}
				settled = true;
				clearInterval(poll);
				resolve(token || syncTokenToStore() || '');
			}

			tokenWaiters.push(finish);

			var poll = setInterval(function () {
				var token = syncTokenToStore();
				if (token) {
					finish(token);
					return;
				}
				if (Date.now() - started >= timeoutMs) {
					finish('');
				}
			}, 150);
		});
	}

	/**
	 * Last resort: a hidden challenge that produced nothing leaves the shopper with an
	 * error and no way to act, so swap it for a visible one (once per page).
	 */
	function revealWidget() {
		var el = widgetEl();
		if (!el || el.getAttribute(REVEALED_ATTR) === 'true') {
			return false;
		}
		// Already visible: re-rendering would only throw away a challenge in progress.
		if ((el.getAttribute('data-appearance') || cfg().appearance || 'always') === 'always') {
			return false;
		}
		return renderWidget(true, 'always');
	}

	/** @return {boolean} Whether a post-attempt reset hook was registered. */
	function registerBlocksValidation() {
		if (typeof wc === 'undefined' || !wc.blocksCheckoutEvents) {
			return false;
		}
		if (typeof wc.blocksCheckoutEvents.onCheckoutValidation === 'function') {
			wc.blocksCheckoutEvents.onCheckoutValidation(function () {
				// The server-side Store API validator is the only authority for whether a
				// token is required. The client only best-effort attaches one and must never
				// block checkout itself: a Turnstile script that is blocked, cached, or mangled
				// by an optimizer — or a checkout page cached while protection was enabled —
				// would otherwise make checkout impossible even after the server stopped
				// requiring a token. When the server still needs one it returns a clear error
				// and onCheckoutFail reveals the widget for an actionable retry.
				if (!hasTurnstileApi() || !cfg().siteKey) {
					return true;
				}
				if (syncTokenToStore()) {
					return true;
				}
				renderWidget(false);
				return waitForToken(4000).then(function (token) {
					if (token) {
						pushExtensionData(token);
					} else {
						// Surface any hidden/interaction challenge so the shopper can solve it
						// on the retry the server is about to trigger.
						revealWidget();
					}
					return true;
				});
			}, 5);
		}
		// The submitted token is now redeemed; a retry must not reuse it.
		if (typeof wc.blocksCheckoutEvents.onCheckoutFail === 'function') {
			wc.blocksCheckoutEvents.onCheckoutFail(function () {
				resetWidget();
				return true;
			});
			return true;
		}
		return false;
	}

	/**
	 * Fallback for Blocks builds without onCheckoutFail: reset once a place-order request
	 * finishes without navigating away.
	 */
	function watchBlocksCheckoutStatus() {
		if (typeof wp === 'undefined' || !wp.data || typeof wp.data.subscribe !== 'function') {
			return;
		}
		wp.data.subscribe(function () {
			var store = wp.data.select('wc/store/checkout');
			if (!store) {
				return;
			}
			var busy = false;
			['isProcessing', 'isBeforeProcessing', 'isAfterProcessing'].forEach(function (fn) {
				if (!busy && typeof store[fn] === 'function') {
					busy = !!store[fn]();
				}
			});
			if (busy) {
				submitting = true;
				return;
			}
			if (submitting) {
				submitting = false;
				resetWidget();
			}
		}, 'wc/store/checkout');
	}

	/** Render as early as possible so the challenge is solved before place-order is clicked. */
	function watchBlocksWidgetPresence() {
		if (typeof wp === 'undefined' || !wp.data || typeof wp.data.subscribe !== 'function') {
			return;
		}
		var lastAttempt = 0;
		wp.data.subscribe(function () {
			var el = widgetEl();
			if (el && activeWidgetId && el.getAttribute(WIDGET_ATTR) === activeWidgetId) {
				return;
			}
			var now = Date.now();
			if (now - lastAttempt < 500) {
				return;
			}
			lastAttempt = now;
			renderWidget(false);
		}, 'wc/store/cart');
	}

	function bootBlocks() {
		if (!registerBlocksValidation()) {
			watchBlocksCheckoutStatus();
		}
		watchBlocksWidgetPresence();
	}

	/**
	 * Turnstile maintains its own hidden `cf-turnstile-response` input inside the checkout
	 * form, so classic submits post the token on their own. This only re-syncs the value
	 * (themes and optimizers occasionally clone or stale the fragment) and records whether
	 * the attempt carried a token, so a failure can reveal a hidden challenge.
	 *
	 * Deliberately never blocks the submit: gating place-order and re-triggering it risks
	 * duplicate orders.
	 */
	function syncClassicHiddenInput(token) {
		var form = token ? document.querySelector('form.checkout') : null;
		if (!form) {
			// Never blank an existing value: Turnstile may hold a token this widget
			// instance cannot read back (for example after a fragment swap).
			return;
		}
		var input = form.querySelector('input[name="cf-turnstile-response"]');
		if (!input) {
			input = document.createElement('input');
			input.type = 'hidden';
			input.name = 'cf-turnstile-response';
			form.appendChild(input);
		}
		input.value = token;
	}

	if (typeof jQuery !== 'undefined') {
		jQuery(document.body).on('checkout_place_order', function () {
			if (!widgetEl()) {
				return true;
			}
			var token = currentToken();
			lastSubmitHadToken = !!token;
			syncClassicHiddenInput(token);
			return true;
		});

		// Classic AJAX checkout failed: the posted token is spent, issue a fresh one.
		jQuery(document.body).on('checkout_error', function () {
			whenTurnstileReady(function () {
				setTimeout(function () {
					if (!lastSubmitHadToken) {
						lastSubmitHadToken = true;
						if (revealWidget()) {
							return;
						}
					}
					resetWidget();
				}, 300);
			});
		});

		// The widget markup lives inside the review-order fragment: tear the instance down
		// before Woo replaces that fragment, otherwise the detached widget keeps retrying.
		jQuery(document.body).on('update_checkout', function () {
			removeActiveWidget();
		});

		jQuery(document.body).on('updated_checkout', function () {
			whenTurnstileReady(function () {
				setTimeout(function () {
					primeWidget();
				}, 200);
			});
		});
	}

	/**
	 * Render on load and, if the challenge stays silent, re-enable the place-order button.
	 * With "Disable submit" the button is CSS-locked until the success callback fires, so a
	 * hidden challenge that never resolves would otherwise leave checkout unusable.
	 */
	function primeWidget() {
		if (!renderWidget(false)) {
			return;
		}
		waitForToken(8000).then(function (token) {
			if (!token) {
				unlockSubmit();
			}
		});
	}

	function boot() {
		whenTurnstileReady(function () {
			primeWidget();
			bootBlocks();
		});
	}

	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', boot);
	} else {
		boot();
	}
})();
