/**
 * ideo Pimp Scrollbar — reusable, drop-in component (no jQuery dependency).
 * Add the `ideo-pimp-scrollbar` class to any element that should scroll;
 * this script wraps its existing children, builds its own track/thumb
 * markup, and switches the element from native scrolling to a JS-driven
 * one. See assets/admin.css for the accompanying styles and the full
 * custom-property list.
 *
 * Sticky headers: native `position: sticky` doesn't work inside this
 * scrollbar (scroll is simulated via `transform`, not native scrollTop).
 * Give a descendant (e.g. a table's <thead> row) the class
 * `ideo-pimp-scrollbar__sticky-top` to pin it to the top edge while the
 * rest scrolls underneath — see the counter-transform in render() below.
 * New elements with that class, added later (e.g. a re-rendered table),
 * are picked up automatically via MutationObserver.
 */
( function () {
	'use strict';

	var FRICTION       = 0.92; // higher = glides further, lower = stops sooner
	var WHEEL_FACTOR    = 0.5;
	var TOUCH_FACTOR    = 1;   // 1:1 finger tracking while dragging
	var DEFAULT_MIN_THUMB = 32;
	var DEFAULT_MAX_THUMB = 120;
	var KEY_STEP        = 40;

	function px( n ) {
		return n + 'px';
	}

	function readMinThumb( root ) {
		var raw = getComputedStyle( root ).getPropertyValue( '--ideo-pimp-scrollbar-thumb-min' );
		var val = parseFloat( raw );
		return isNaN( val ) ? DEFAULT_MIN_THUMB : val;
	}

	function readMaxThumb( root ) {
		var raw = getComputedStyle( root ).getPropertyValue( '--ideo-pimp-scrollbar-thumb-max' );
		var val = parseFloat( raw );
		return isNaN( val ) ? DEFAULT_MAX_THUMB : val;
	}

	function buildTrack( axis ) {
		var track = document.createElement( 'div' );
		track.className = 'ideo-pimp-scrollbar__track ideo-pimp-scrollbar__track--' + axis;
		track.setAttribute( 'role', 'scrollbar' );
		track.setAttribute( 'aria-orientation', axis === 'y' ? 'vertical' : 'horizontal' );

		var thumb = document.createElement( 'div' );
		thumb.className = 'ideo-pimp-scrollbar__thumb';
		track.appendChild( thumb );

		return { track: track, thumb: thumb };
	}

	function initIdeoPimpScrollbar( root ) {
		if ( root.dataset.ideoPimpScrollbarInit ) return; // never double-initialize
		root.dataset.ideoPimpScrollbarInit = '1';

		// Move the element's existing children into a content wrapper that
		// gets transformed, instead of scrolling the element itself.
		var content = document.createElement( 'div' );
		content.className = 'ideo-pimp-scrollbar__content';
		while ( root.firstChild ) {
			content.appendChild( root.firstChild );
		}
		root.appendChild( content );

		var trackY = buildTrack( 'y' );
		var trackX = buildTrack( 'x' );
		root.appendChild( trackY.track );
		root.appendChild( trackX.track );

		// Descendants pinned to the top edge while the rest scrolls underneath
		// (see the header comment above). Re-collected on any DOM change inside
		// `content`, so content swapped in later keeps working automatically.
		var stickyEls = [];
		function collectSticky() {
			stickyEls = Array.prototype.slice.call( content.querySelectorAll( '.ideo-pimp-scrollbar__sticky-top' ) );
		}
		collectSticky();
		// Sequence-guards the pairing with the ResizeObserver below: see the
		// comment there for why a resize-triggered measure() needs to know
		// how recently a mutation-triggered one already ran.
		var lastMutationMeasureAt = 0;
		if ( window.MutationObserver ) {
			// Also re-measure: content swapped in later (e.g. a live table
			// re-rendered after a column is added/removed) changes scrollWidth
			// without changing `content`'s own box size, so the ResizeObserver
			// below never fires for it — this is the only signal that catches it.
			new MutationObserver( function () {
				collectSticky();
				measure();
				lastMutationMeasureAt = Date.now();
			} ).observe( content, { childList: true, subtree: true } );
		}

		root.classList.add( 'ideo-pimp-scrollbar--js' );
		if ( ! root.hasAttribute( 'tabindex' ) ) {
			root.setAttribute( 'tabindex', '0' );
		}

		var state = {
			top: 0, left: 0,
			maxTop: 0, maxLeft: 0,
			trackYSize: 0, trackXSize: 0,
			thumbYSize: 0, thumbXSize: 0,
			thumbYPos: 0, thumbXPos: 0,
			hasY: false, hasX: false
		};

		var velocityY = 0, velocityX = 0, raf = null, scrollingTimer = null;

		function markScrolling() {
			root.classList.add( 'is-scrolling' );
			clearTimeout( scrollingTimer );
			scrollingTimer = setTimeout( function () {
				root.classList.remove( 'is-scrolling' );
			}, 400 );
		}

		function clamp( value, max ) {
			return Math.min( Math.max( value, 0 ), max );
		}

		function measure() {
			var viewportH = root.clientHeight;
			var viewportW = root.clientWidth;
			var contentH  = content.scrollHeight;
			var contentW  = content.scrollWidth;

			state.maxTop  = Math.max( 0, contentH - viewportH );
			state.maxLeft = Math.max( 0, contentW - viewportW );
			state.hasY = state.maxTop > 0;
			state.hasX = state.maxLeft > 0;

			// A sticky element only counter-transforms vertically — horizontally it
			// still follows the content's own translateX. If the element is narrower
			// than the full scrollable width (e.g. a toolbar/tab bar that isn't a
			// table row and doesn't auto-inherit the table's width), scrolling past
			// its own width leaves a gap with no opaque background at that row, and
			// whatever scrolls past underneath (clipped everywhere else by the root's
			// overflow:hidden) becomes visible through it. Stretch every sticky
			// element to at least the full content width so its background always
			// covers the entire horizontal scroll range, not just the initial viewport.
			for ( var i = 0; i < stickyEls.length; i++ ) {
				stickyEls[ i ].style.minWidth = px( contentW );
			}

			trackY.track.hidden = ! state.hasY;
			trackX.track.hidden = ! state.hasX;
			trackY.track.setAttribute( 'aria-hidden', String( ! state.hasY ) );
			trackX.track.setAttribute( 'aria-hidden', String( ! state.hasX ) );

			// Reserve a corner square when both axes are visible, so the
			// two tracks stop short of each other instead of crossing.
			root.classList.toggle( 'ideo-pimp-scrollbar--corner', state.hasY && state.hasX );

			state.trackYSize = trackY.track.clientHeight;
			state.trackXSize = trackX.track.clientWidth;

			var minThumb = readMinThumb( root );
			var maxThumb = readMaxThumb( root );
			state.thumbYSize = state.hasY
				? Math.min( state.trackYSize, maxThumb, Math.max( minThumb, Math.round( ( viewportH / contentH ) * state.trackYSize ) ) )
				: 0;
			state.thumbXSize = state.hasX
				? Math.min( state.trackXSize, maxThumb, Math.max( minThumb, Math.round( ( viewportW / contentW ) * state.trackXSize ) ) )
				: 0;

			trackY.thumb.style.height = px( state.thumbYSize );
			trackX.thumb.style.width  = px( state.thumbXSize );

			state.top  = clamp( state.top, state.maxTop );
			state.left = clamp( state.left, state.maxLeft );

			render();
		}

		function render() {
			content.style.transform = 'translate3d(' + ( -state.left ) + 'px,' + ( -state.top ) + 'px,0)';

			// Cancel just the vertical component of the content transform above,
			// so these elements stay put on the Y axis but still follow along on X.
			for ( var i = 0; i < stickyEls.length; i++ ) {
				stickyEls[ i ].style.transform = 'translate3d(0,' + state.top + 'px,0)';
			}

			if ( state.hasY ) {
				var travelY = state.trackYSize - state.thumbYSize;
				state.thumbYPos = state.maxTop > 0 ? ( state.top / state.maxTop ) * travelY : 0;
				trackY.thumb.style.transform = 'translateY(' + state.thumbYPos + 'px)';
				trackY.track.setAttribute( 'aria-valuemin', '0' );
				trackY.track.setAttribute( 'aria-valuemax', String( state.maxTop ) );
				trackY.track.setAttribute( 'aria-valuenow', String( Math.round( state.top ) ) );
			}
			if ( state.hasX ) {
				var travelX = state.trackXSize - state.thumbXSize;
				state.thumbXPos = state.maxLeft > 0 ? ( state.left / state.maxLeft ) * travelX : 0;
				trackX.thumb.style.transform = 'translateX(' + state.thumbXPos + 'px)';
				trackX.track.setAttribute( 'aria-valuemin', '0' );
				trackX.track.setAttribute( 'aria-valuemax', String( state.maxLeft ) );
				trackX.track.setAttribute( 'aria-valuenow', String( Math.round( state.left ) ) );
			}
		}

		function setScroll( top, left ) {
			state.top  = clamp( top, state.maxTop );
			state.left = clamp( left, state.maxLeft );
			render();
			markScrolling();
		}

		// ── Wheel: inertia on both axes ────────────────────────────────
		function animate() {
			var moving = false;

			if ( Math.abs( velocityY ) >= 0.5 ) {
				var nextTop = state.top + velocityY;
				if ( nextTop <= 0 || nextTop >= state.maxTop ) velocityY = 0;
				state.top = clamp( nextTop, state.maxTop );
				if ( velocityY !== 0 ) { velocityY *= FRICTION; moving = true; }
			} else {
				velocityY = 0;
			}

			if ( Math.abs( velocityX ) >= 0.5 ) {
				var nextLeft = state.left + velocityX;
				if ( nextLeft <= 0 || nextLeft >= state.maxLeft ) velocityX = 0;
				state.left = clamp( nextLeft, state.maxLeft );
				if ( velocityX !== 0 ) { velocityX *= FRICTION; moving = true; }
			} else {
				velocityX = 0;
			}

			render();
			raf = moving ? requestAnimationFrame( animate ) : null;
		}

		root.addEventListener( 'wheel', function ( e ) {
			// Resolve axis intent once per event, same as native scrolling:
			// Shift+wheel or a horizontally-dominant delta means horizontal.
			var isHorizontal = e.shiftKey || Math.abs( e.deltaX ) > Math.abs( e.deltaY );

			if ( isHorizontal ) {
				if ( ! state.hasX ) return; // nothing to scroll on this axis — let it bubble
				// Shift+wheel is reported differently across browsers: some
				// already convert it to deltaX (deltaY left at 0), others
				// report the original vertical delta in deltaY with the
				// shift flag set. Trust deltaX whenever it's actually non-zero.
				var deltaH  = e.deltaX !== 0 ? e.deltaX : e.deltaY;
				var atLeft  = state.left <= 0;
				var atRight = state.left >= state.maxLeft;
				if ( ( atLeft && deltaH < 0 ) || ( atRight && deltaH > 0 ) ) {
					return; // let it bubble instead of trapping the scroll
				}
				e.preventDefault();
				velocityX += deltaH * WHEEL_FACTOR;
				markScrolling();
				if ( ! raf ) raf = requestAnimationFrame( animate );
				return;
			}

			if ( ! state.hasY ) return;
			var atTop    = state.top <= 0;
			var atBottom = state.top >= state.maxTop;
			if ( ( atTop && e.deltaY < 0 ) || ( atBottom && e.deltaY > 0 ) ) {
				return; // let it bubble instead of trapping the scroll
			}
			e.preventDefault();
			velocityY += e.deltaY * WHEEL_FACTOR;
			markScrolling();
			if ( ! raf ) raf = requestAnimationFrame( animate );
		}, { passive: false } );

		// ── Drag either thumb to scroll ────────────────────────────────
		function bindThumbDrag( thumb, axis ) {
			thumb.addEventListener( 'pointerdown', function ( e ) {
				e.preventDefault();
				e.stopPropagation(); // don't also trigger the track's own click-to-page handler
				thumb.setPointerCapture( e.pointerId );

				var startPointer = axis === 'y' ? e.clientY : e.clientX;
				var startScroll  = axis === 'y' ? state.top : state.left;
				var travel       = axis === 'y' ? ( state.trackYSize - state.thumbYSize ) : ( state.trackXSize - state.thumbXSize );
				var maxScroll    = axis === 'y' ? state.maxTop : state.maxLeft;

				velocityY = 0; velocityX = 0; // a fresh drag overrides any ongoing wheel inertia

				function onMove( ev ) {
					var delta = ( axis === 'y' ? ev.clientY : ev.clientX ) - startPointer;
					var ratio = travel > 0 ? delta / travel : 0;
					var next  = startScroll + ratio * maxScroll;
					if ( axis === 'y' ) setScroll( next, state.left );
					else setScroll( state.top, next );
				}
				function onUp() {
					thumb.releasePointerCapture( e.pointerId );
					document.removeEventListener( 'pointermove', onMove );
					document.removeEventListener( 'pointerup', onUp );
				}
				document.addEventListener( 'pointermove', onMove );
				document.addEventListener( 'pointerup', onUp );
			} );
		}
		bindThumbDrag( trackY.thumb, 'y' );
		bindThumbDrag( trackX.thumb, 'x' );

		// ── Click a track (not the thumb itself) to page in that direction ──
		function bindTrackClick( track, thumb, axis ) {
			track.addEventListener( 'pointerdown', function ( e ) {
				if ( e.target === thumb ) return; // handled by bindThumbDrag instead
				var rect      = track.getBoundingClientRect();
				var clickPos  = axis === 'y' ? e.clientY - rect.top : e.clientX - rect.left;
				var thumbPos  = axis === 'y' ? state.thumbYPos : state.thumbXPos;
				var viewport  = axis === 'y' ? root.clientHeight : root.clientWidth;
				var direction = clickPos < thumbPos ? -1 : 1;
				if ( axis === 'y' ) setScroll( state.top + direction * viewport * 0.9, state.left );
				else setScroll( state.top, state.left + direction * viewport * 0.9 );
			} );
		}
		bindTrackClick( trackY.track, trackY.thumb, 'y' );
		bindTrackClick( trackX.track, trackX.thumb, 'x' );

		// ── Touch: drag the content directly, with inertia on release ───
		content.addEventListener( 'pointerdown', function ( e ) {
			if ( e.pointerType !== 'touch' ) return; // mice/pens keep native text selection etc.
			content.setPointerCapture( e.pointerId );

			var lastX = e.clientX, lastY = e.clientY, lastT = performance.now();
			velocityX = 0; velocityY = 0;
			if ( raf ) { cancelAnimationFrame( raf ); raf = null; }

			function onMove( ev ) {
				var now = performance.now();
				var dt  = Math.max( 1, now - lastT );
				var dx  = ev.clientX - lastX;
				var dy  = ev.clientY - lastY;

				velocityX = ( -dx / dt ) * 16; // px per ~frame, same units animate() expects
				velocityY = ( -dy / dt ) * 16;

				setScroll( state.top - dy * TOUCH_FACTOR, state.left - dx * TOUCH_FACTOR );

				lastX = ev.clientX; lastY = ev.clientY; lastT = now;
				ev.preventDefault();
			}
			function onUp() {
				content.releasePointerCapture( e.pointerId );
				document.removeEventListener( 'pointermove', onMove );
				document.removeEventListener( 'pointerup', onUp );
				if ( ! raf && ( Math.abs( velocityX ) >= 0.5 || Math.abs( velocityY ) >= 0.5 ) ) {
					raf = requestAnimationFrame( animate );
				}
			}
			document.addEventListener( 'pointermove', onMove, { passive: false } );
			document.addEventListener( 'pointerup', onUp );
		} );

		// ── Keyboard, only when the element itself (not a child) is focused ──
		root.addEventListener( 'keydown', function ( e ) {
			if ( e.target !== root ) return;
			switch ( e.key ) {
				case 'ArrowDown':  setScroll( state.top + KEY_STEP, state.left ); break;
				case 'ArrowUp':    setScroll( state.top - KEY_STEP, state.left ); break;
				case 'ArrowRight': setScroll( state.top, state.left + KEY_STEP ); break;
				case 'ArrowLeft':  setScroll( state.top, state.left - KEY_STEP ); break;
				case 'PageDown':   setScroll( state.top + root.clientHeight * 0.9, state.left ); break;
				case 'PageUp':     setScroll( state.top - root.clientHeight * 0.9, state.left ); break;
				case 'Home':       setScroll( 0, state.left ); break;
				case 'End':        setScroll( state.maxTop, state.left ); break;
				default: return;
			}
			e.preventDefault();
		} );

		// ── Re-measure on resize (of the element itself or its content) ──
		if ( window.ResizeObserver ) {
			var ro = new ResizeObserver( function () {
				// A structural change (e.g. a live table column removed) is
				// immediately followed by this same content's own FLIP-reorder
				// animation translateX-ing its now-shifted cells back into
				// place (see animateReorder() in the live-table code) — while
				// that's playing, `content`'s reported size can transiently
				// reflect cells still painted at their old, wider position,
				// so a ResizeObserver firing in that ~500ms window re-measures
				// a stale/inflated width and corrupts state.maxLeft with
				// nothing to ever correct it afterward (ResizeObserver only
				// fires again on a genuine subsequent size change, and the
				// animation settling back to the size the mutation observer
				// already measured correctly doesn't count as one). The
				// mutation-triggered measure() just above is authoritative
				// for that case, so skip this one while it's still recent.
				if ( Date.now() - lastMutationMeasureAt < 600 ) return;
				measure();
			} );
			ro.observe( root );
			ro.observe( content );
		} else {
			window.addEventListener( 'resize', measure );
		}

		// ── Scroll a given descendant into view, eased over a few frames
		// (e.g. a column just brought back from the "excluded" list, so the
		// user can see where it landed instead of hunting for it). `root`
		// may itself sit inside an ancestor CSS scale() transform (the live
		// table's zoom control) — getBoundingClientRect bakes that scale
		// into every rect it returns, so screen-space deltas are divided
		// back out via the ratio between root's rendered and layout width.
		function scrollToElement( el, opts ) {
			opts = opts || {};
			var padding = opts.padding != null ? opts.padding : 24;
			measure();

			var scale       = root.getBoundingClientRect().width / root.clientWidth || 1;
			var elRect      = el.getBoundingClientRect();
			var contentRect = content.getBoundingClientRect();

			// contentRect already reflects the current scroll translate, so
			// this ratio is el's position in content's natural (unscrolled)
			// coordinate space directly — adding state.left again here would
			// double-count the current scroll offset.
			var elLeft = ( elRect.left - contentRect.left ) / scale;
			var elW    = elRect.width  / scale;

			var targetLeft = state.left;
			if ( elLeft - padding < state.left ) {
				targetLeft = elLeft - padding;
			} else if ( elLeft + elW + padding > state.left + root.clientWidth ) {
				targetLeft = elLeft + elW + padding - root.clientWidth;
			}

			var targetTop = state.top;
			if ( false !== opts.vertical ) {
				var elTop = ( elRect.top - contentRect.top ) / scale;
				var elH   = elRect.height / scale;
				if ( elTop - padding < state.top ) {
					targetTop = elTop - padding;
				} else if ( elTop + elH + padding > state.top + root.clientHeight ) {
					targetTop = elTop + elH + padding - root.clientHeight;
				}
			}

			targetLeft = clamp( targetLeft, state.maxLeft );
			targetTop  = clamp( targetTop, state.maxTop );
			if ( targetLeft === state.left && targetTop === state.top ) return;

			var startLeft = state.left, startTop = state.top, startTime = null;
			var duration  = 300;
			function step( ts ) {
				if ( ! startTime ) startTime = ts;
				var t     = Math.min( 1, ( ts - startTime ) / duration );
				var eased = 1 - Math.pow( 1 - t, 3 );
				setScroll( startTop + ( targetTop - startTop ) * eased, startLeft + ( targetLeft - startLeft ) * eased );
				if ( t < 1 ) requestAnimationFrame( step );
			}
			requestAnimationFrame( step );
		}
		root.ideoPimpScrollbar = { scrollToElement: scrollToElement };

		measure();
	}

	function initAll( root ) {
		( root || document ).querySelectorAll( '.ideo-pimp-scrollbar' ).forEach( initIdeoPimpScrollbar );
	}

	document.addEventListener( 'DOMContentLoaded', function () { initAll(); } );

	// For content added to the page later (e.g. after an AJAX call):
	// IdeoPimpScrollbar.initOne(el) / IdeoPimpScrollbar.initAll(container)
	window.IdeoPimpScrollbar = {
		initOne: initIdeoPimpScrollbar,
		initAll: initAll,
		scrollToElement: function( root, el, opts ) {
			if ( root && root.ideoPimpScrollbar ) root.ideoPimpScrollbar.scrollToElement( el, opts );
		}
	};
} )();

jQuery( function( $ ) {

	function escapeHtml( s ) {
		return $( '<span>' ).text( null == s ? '' : s ).html();
	}

	// ── Toast notifications — same look as the Settings page's post-save
	// toast (.ifp-toast, styled in admin.css), but built and dismissed purely
	// in JS since this page never reloads after an action like Settings does.
	function showToast( message, type ) {
		var toast = document.createElement( 'div' );
		toast.className = 'ifp-toast ifp-toast--' + ( type || 'success' );
		var text = document.createElement( 'p' );
		text.textContent = message;
		var closeBtn = document.createElement( 'button' );
		closeBtn.type = 'button';
		closeBtn.className = 'ifp-toast__close';
		closeBtn.innerHTML = '&#x2715;';
		function dismiss() {
			toast.style.transition = 'opacity 0.6s ease, transform 0.6s ease';
			toast.style.opacity = '0';
			toast.style.transform = 'translateY(-8px)';
			setTimeout( function() { if ( toast.parentElement ) toast.remove(); }, 650 );
		}
		closeBtn.addEventListener( 'click', dismiss );
		toast.appendChild( text );
		toast.appendChild( closeBtn );
		document.body.appendChild( toast );
		setTimeout( dismiss, 4000 );
	}

	// ── File drag & drop (import) ─────────────────────────────────────────────
	var area      = document.getElementById( 'drag-drop-area' );
	var input     = document.getElementById( 'excel_file' );
	var ddText    = document.getElementById( 'dd-text' );
	var ddFile    = document.getElementById( 'dd-file' );
	var ddRemove  = document.getElementById( 'dd-remove' );

	function showFile( file ) {
		if ( ddText )   ddText.style.display   = 'none';
		if ( ddFile )   { ddFile.style.display = 'block'; ddFile.textContent = file.name; }
		if ( ddRemove ) ddRemove.classList.remove( 'ifp-hidden' );
		if ( area )     { area.style.background = getComputedStyle( document.body ).getPropertyValue( '--bg' ).trim(); }
	}

	function clearFile() {
		if ( input )    input.value = '';
		if ( ddText )   ddText.style.display   = 'block';
		if ( ddFile )   { ddFile.style.display = 'none'; ddFile.textContent = ''; }
		if ( ddRemove ) ddRemove.classList.add( 'ifp-hidden' );
		if ( area )     { area.style.borderColor = '#c3c4c7'; area.style.background = ''; }
	}

	if ( area && input ) {
		area.addEventListener( 'click', function(e) {
			if ( e.target !== ddRemove && ! ddRemove.contains( e.target ) ) {
				input.click();
			}
		} );
		input.addEventListener( 'change', function() {
			if ( input.files.length ) showFile( input.files[0] );
		} );
		area.addEventListener( 'dragover', function(e) {
			e.preventDefault();
			area.style.background = getComputedStyle( document.body ).getPropertyValue( '--medium' ).trim();
		} );
		area.addEventListener( 'dragleave', function() {
			if ( ! input.files.length ) area.style.background = '';
		} );
		area.addEventListener( 'drop', function(e) {
			e.preventDefault();
			if ( e.dataTransfer.files.length ) {
				input.files = e.dataTransfer.files;
				showFile( e.dataTransfer.files[0] );
			}
		} );
	}

	if ( ddRemove ) {
		ddRemove.addEventListener( 'click', function(e) {
			e.stopPropagation();
			clearFile();
		} );
	}

	// ── Load a mapping profile ────────────────────────────────────────────────
	$( document ).on( 'click', '.ifp-profile-load', function() {
		var profileName = $( this ).attr( 'data-profile' );
		if ( typeof ideoFpProfiles === 'undefined' || ! ideoFpProfiles.hasOwnProperty( profileName ) ) return;
		var profile = ideoFpProfiles[ profileName ];
		// Support old format (plain mapping object) and new format ({mapping, separators}).
		var mapping    = profile.mapping    !== undefined ? profile.mapping    : profile;
		var separators = profile.separators !== undefined ? profile.separators : {};
		// Restore destination field selections.
		$( '.ifp-select' ).val( '' );
		$( '.ifp-select' ).each( function() {
			var col = $( this ).attr( 'data-column' );
			if ( col !== undefined && mapping.hasOwnProperty( col ) ) $( this ).val( mapping[ col ] );
			toggleSeparator( this );
		} );
		// Restore separator selections.
		$( '.ifp-sep-select' ).each( function() {
			var col = $( this ).attr( 'data-col' );
			if ( col !== undefined && separators.hasOwnProperty( col ) ) {
				$( this ).val( separators[ col ] );
				// Sync hidden input.
				var hidden = $( this ).closest( 'td, .ifp-sep-cell' ).find( '.ifp-sep-hidden' );
				if ( hidden.length ) hidden.val( separators[ col ] );
			}
		} );
		// Confirm the load: a toast (easy to miss a silent table refresh
		// otherwise) plus a brief flash on every row so the change is visible
		// even without reading the toast text.
		showToast( ( ideoFpI18n.profileLoaded || 'Profile loaded: %s' ).replace( '%s', profileName ) );
		var rows = document.querySelectorAll( '.ifp-mapping-row' );
		rows.forEach( function( row ) { row.classList.add( 'ifp-live-col--flash' ); } );
		setTimeout( function() {
			rows.forEach( function( row ) { row.classList.remove( 'ifp-live-col--flash' ); } );
		}, 1000 );
	} );

	// ── Reset to the default mapping — discards every manual/profile change
	// (destination fields + separators) back to what the plugin auto-detects,
	// exactly as if the page had just loaded fresh. Bound to the permanent
	// "Default" chip, never one more entry in the saved-profiles list. ───────
	$( '#ifp-import-default-btn' ).on( 'click', function() {
		if ( ! window.confirm( ideoFpI18n.confirmResetMapping || 'Reset the mapping to the default (auto-detected) configuration?' ) ) return;
		var defaultMap = ( typeof ideoFpDefaultMapping !== 'undefined' ) ? ideoFpDefaultMapping : {};
		$( '.ifp-select' ).val( '' );
		$( '.ifp-select' ).each( function() {
			var col = $( this ).attr( 'data-column' );
			if ( col !== undefined && defaultMap.hasOwnProperty( col ) ) $( this ).val( defaultMap[ col ] );
			toggleSeparator( this );
		} );
		$( '.ifp-sep-select' ).each( function() {
			$( this ).val( '|' );
			var hidden = $( this ).closest( 'td, .ifp-sep-cell' ).find( '.ifp-sep-hidden' );
			if ( hidden.length ) hidden.val( '|' );
		} );
	} );

	// ── Save a mapping profile (AJAX, no-reload — no full page reload means
	// no jump back to the top of the page, and the new chip appears right in
	// the "Load profile" card next to where the user is looking). ──────────
	function addImportProfileChip( name ) {
		var card = document.getElementById( 'ifp-import-profiles-card' );
		if ( ! card ) return;
		var list = card.querySelector( '.ifp-profiles-list' );
		if ( ! list ) {
			list = document.createElement( 'div' );
			list.className = 'ifp-profiles-list';
			card.appendChild( list );
		}
		// The permanent "Default" chip lives in this same list (PHP-rendered),
		// so the empty-state message sits inside it too, not as a sibling —
		// drop it once a real profile is about to be added.
		var empty = list.querySelector( '.ifp-card__empty' );
		if ( empty ) empty.remove();
		// Re-saving an existing name overwrites the stored profile server-side
		// (not a duplicate) — drop the old chip so the list doesn't end up
		// with two entries for the same profile.
		var existingLoad = list.querySelector( '.ifp-profile-load[data-profile="' + name.replace( /"/g, '\\"' ) + '"]' );
		if ( existingLoad ) {
			var existingChip = existingLoad.closest( '.ifp-profile-chip' );
			if ( existingChip ) existingChip.remove();
		}
		var chip = document.createElement( 'div' );
		chip.className = 'ifp-profile-chip';
		chip.innerHTML =
			'<button type="button" class="ifp-profile-load button button-small" data-profile="' + escapeHtml( name ) + '">' + escapeHtml( name ) + '</button>' +
			'<form class="ifp-profile-delete-form">' +
				'<input type="hidden" name="ideo_fp_nonce" value="' + escapeHtml( ideoFpI18n.deleteProfileNonce ) + '">' +
				'<input type="hidden" name="action" value="' + escapeHtml( ideoFpI18n.deleteProfileAction ) + '">' +
				'<input type="hidden" name="profile_name" value="' + escapeHtml( name ) + '">' +
				'<input type="hidden" name="profile_post_type" value="' + escapeHtml( ideoFpI18n.importProfileType || '' ) + '">' +
				'<button type="submit" class="ifp-profile-delete" title="' + escapeHtml( ideoFpI18n.deleteProfile || 'Delete this profile' ) + '">×</button>' +
			'</form>';
		list.appendChild( chip );
	}

	// ── Delete a mapping profile (AJAX, no-reload — mirrors the save flow
	// above, so removing a chip doesn't jump the page back to the top). ────────
	$( document ).on( 'submit', '.ifp-profile-delete-form', function( e ) {
		e.preventDefault();
		if ( ! window.confirm( ideoFpI18n.confirmDelete ) ) return;
		var $form = $( this );
		var $chip = $form.closest( '.ifp-profile-chip' );
		var name  = $form.find( 'input[name="profile_name"]' ).val();
		$.ajax( {
			url: ideoFpI18n.ajaxUrl,
			method: 'POST',
			data: $form.serialize(),
			success: function( response ) {
				if ( ! response.success ) return;
				if ( typeof ideoFpProfiles !== 'undefined' && name ) delete ideoFpProfiles[ name ];
				var $list = $chip.closest( '.ifp-profiles-list' );
				$chip.remove();
				// The permanent "Default" chip always stays in this list — "empty"
				// means no *saved* profile chips remain next to it.
				if ( $list.length && ! $list.find( '.ifp-profile-chip:not(.ifp-profile-chip--danger)' ).length ) {
					$list.append( '<p class="ifp-card__empty">' + escapeHtml( ideoFpI18n.noSavedProfiles ) + '</p>' );
				}
			}
		} );
	} );

	$( '#ifp-btn-save-profile' ).on( 'click', function() {
		var nameInput = $( '#ifp-profile-name' );
		var name = nameInput.val().trim();
		if ( ! name ) { nameInput.focus(); return; }
		var mapping    = {};
		var separators = {};
		$( '.ifp-select' ).each( function() {
			var col = $( this ).attr( 'data-column' );
			var val = $( this ).val();
			if ( col !== undefined && val ) mapping[ col ] = val;
		} );
		// Save separator values (only for visible/active sep selects).
		$( '.ifp-sep-hidden' ).each( function() {
			var name_attr = $( this ).attr( 'name' ); // separator[col]
			var match = name_attr && name_attr.match( /separator\[([^\]]+)\]/ );
			if ( match ) separators[ match[1] ] = $( this ).val();
		} );

		$.ajax( {
			url: ideoFpI18n.ajaxUrl,
			method: 'POST',
			data: {
				action: ideoFpI18n.saveImportProfileAction,
				nonce: ideoFpI18n.importProfileNonce,
				profile_name: name,
				profile_mapping: JSON.stringify( { mapping: mapping, separators: separators } ),
				profile_post_type: ideoFpI18n.importProfileType || ''
			},
			success: function( response ) {
				if ( response.success ) {
					if ( typeof ideoFpProfiles !== 'undefined' ) ideoFpProfiles[ name ] = { mapping: mapping, separators: separators };
					nameInput.val( '' );
					addImportProfileChip( name );
				}
			}
		} );
	} );

	// ── Duplicate-titles modal (Import) ───────────────────────────────────────
	// Shown before "Launch import"/"Test import" actually run, if the file's
	// title column has values matching already-published posts — lets the
	// user pick Replace/Ignore for this run instead of a global setting.
	var dupeModalResolve = null;
	var dupeAllTitles     = []; // full duplicate list for the currently-open modal — "Replace all" target when nothing is checked.

	function ensureDupeModal() {
		if ( document.getElementById( 'ifp-dupe-modal' ) ) return;
		var modal = document.createElement( 'div' );
		modal.id = 'ifp-dupe-modal';
		modal.innerHTML =
			'<div id="ifp-dupe-backdrop"></div>' +
			'<div id="ifp-dupe-dialog" role="dialog" aria-modal="true">' +
				'<div id="ifp-dupe-header">' +
					'<div id="ifp-dupe-title"></div>' +
					'<button type="button" id="ifp-dupe-close" title="' + escapeHtml( ideoFpI18n.dupeClose ) + '">✕</button>' +
				'</div>' +
				'<div id="ifp-dupe-body">' +
					'<p>' + escapeHtml( ideoFpI18n.dupeModalIntro ) + '</p>' +
					'<ul id="ifp-dupe-list"></ul>' +
				'</div>' +
				'<div id="ifp-dupe-footer">' +
					'<button type="button" id="ifp-dupe-deselect" class="button">' + escapeHtml( ideoFpI18n.dupeDeselectAll ) + '</button>' +
					'<button type="button" id="ifp-dupe-ignore" class="button">' + escapeHtml( ideoFpI18n.dupeIgnore ) + '</button>' +
					'<button type="button" id="ifp-dupe-replace" class="button button-primary">' + escapeHtml( ideoFpI18n.dupeReplaceAll ) + '</button>' +
				'</div>' +
			'</div>';
		document.body.appendChild( modal );

		// Closing via backdrop/✕/Escape cancels — only the footer's Ignore/Replace
		// buttons resolve a choice and let the import proceed.
		document.getElementById( 'ifp-dupe-backdrop' ).addEventListener( 'click', function() { closeDupeModal( null ); } );
		document.getElementById( 'ifp-dupe-close' ).addEventListener( 'click', function() { closeDupeModal( null ); } );
		document.addEventListener( 'keydown', function( e ) {
			if ( 'Escape' === e.key && modal.classList.contains( 'ifp-dupe-modal--open' ) ) closeDupeModal( null );
		} );
		document.getElementById( 'ifp-dupe-ignore' ).addEventListener( 'click', function() { closeDupeModal( 'ignore', [] ); } );
		document.getElementById( 'ifp-dupe-replace' ).addEventListener( 'click', function() {
			var selected = getCheckedDupeTitles();
			closeDupeModal( 'replace', selected.length ? selected : dupeAllTitles );
		} );
		document.getElementById( 'ifp-dupe-deselect' ).addEventListener( 'click', function() {
			document.querySelectorAll( '#ifp-dupe-list input[type="checkbox"]' ).forEach( function( cb ) { cb.checked = false; } );
			updateDupeReplaceButtonLabel();
		} );
		// Event delegation: list is rebuilt on every open, a per-checkbox listener
		// would leak/duplicate — one listener on the (persistent) <ul> covers all.
		document.getElementById( 'ifp-dupe-list' ).addEventListener( 'change', function( e ) {
			if ( e.target && 'checkbox' === e.target.type ) updateDupeReplaceButtonLabel();
		} );
	}

	function getCheckedDupeTitles() {
		return Array.prototype.map.call(
			document.querySelectorAll( '#ifp-dupe-list input[type="checkbox"]:checked' ),
			function( cb ) { return cb.value; }
		);
	}

	// Nothing checked → bulk shortcut ("Replace all"); ≥1 checked → scoped to
	// that selection only ("Replace selected") — the label itself communicates
	// which of the two the click is about to do.
	function updateDupeReplaceButtonLabel() {
		var btn = document.getElementById( 'ifp-dupe-replace' );
		if ( ! btn ) return;
		btn.textContent = getCheckedDupeTitles().length ? ideoFpI18n.dupeReplaceSelected : ideoFpI18n.dupeReplaceAll;
	}

	function openDupeModal( titles, onResolve ) {
		ensureDupeModal();
		dupeModalResolve = onResolve;
		dupeAllTitles = titles.slice();
		var titleEl = document.getElementById( 'ifp-dupe-title' );
		var titleTpl = 1 === titles.length ? ideoFpI18n.dupeModalTitleSingle : ideoFpI18n.dupeModalTitlePlural;
		titleEl.textContent = titleTpl.replace( '%d', titles.length );
		var list = document.getElementById( 'ifp-dupe-list' );
		list.innerHTML = '';
		titles.forEach( function( t ) {
			var li    = document.createElement( 'li' );
			var label = document.createElement( 'label' );
			label.className = 'ifp-dupe-item';
			var checkbox = document.createElement( 'input' );
			checkbox.type  = 'checkbox';
			checkbox.value = t;
			var span = document.createElement( 'span' );
			span.textContent = t;
			label.appendChild( checkbox );
			label.appendChild( span );
			li.appendChild( label );
			list.appendChild( li );
		} );
		updateDupeReplaceButtonLabel();
		var modal = document.getElementById( 'ifp-dupe-modal' );
		requestAnimationFrame( function() {
			requestAnimationFrame( function() { modal.classList.add( 'ifp-dupe-modal--open' ); } );
		} );
		document.body.style.overflow = 'hidden';
	}

	function closeDupeModal( choice, titlesToReplace ) {
		var modal = document.getElementById( 'ifp-dupe-modal' );
		if ( modal ) modal.classList.remove( 'ifp-dupe-modal--open' );
		document.body.style.overflow = '';
		var resolve = dupeModalResolve;
		dupeModalResolve = null;
		if ( resolve ) resolve( choice, titlesToReplace || [] );
	}

	// ── Intercept "Launch import" / "Test import" to run the duplicate check first ──
	var mappingForm = document.getElementById( 'ifp-mapping-form' );
	if ( mappingForm ) {
		var dupeTitlesInput = mappingForm.querySelector( 'input[name="duplicate_titles_replace"]' );
		if ( ! dupeTitlesInput ) {
			dupeTitlesInput = document.createElement( 'input' );
			dupeTitlesInput.type  = 'hidden';
			dupeTitlesInput.name  = 'duplicate_titles_replace';
			dupeTitlesInput.value = '[]';
			mappingForm.appendChild( dupeTitlesInput );
		}

		mappingForm.addEventListener( 'submit', function( e ) {
			// Re-submission after the check already ran for this click — let it through.
			if ( mappingForm.dataset.dupeResolved ) {
				delete mappingForm.dataset.dupeResolved;
				return;
			}
			var submitter = e.submitter;
			// Test import is a PRO-only feature (its button is disabled/type="button"
			// in the free plugin, so it can never be the submitter here) — no isTest
			// branch to keep the PRO-only logic out of the free bundle entirely.
			if ( ! submitter || 'submit' !== submitter.name ) return; // some other control inside the form

			e.preventDefault();

			var mapping = {};
			document.querySelectorAll( '.ifp-select' ).forEach( function( sel ) {
				var col = sel.getAttribute( 'data-column' );
				var val = sel.value;
				if ( col && val ) mapping[ col ] = val;
			} );

			var isInputEl = 'INPUT' === submitter.tagName;
			var origLabel = isInputEl ? submitter.value : submitter.textContent;
			submitter.disabled = true;
			if ( isInputEl ) submitter.value = ideoFpI18n.checkingDuplicates; else submitter.textContent = ideoFpI18n.checkingDuplicates;

			function restoreButton() {
				submitter.disabled = false;
				if ( isInputEl ) submitter.value = origLabel; else submitter.textContent = origLabel;
			}
			function resubmit() {
				mappingForm.dataset.dupeResolved = '1';
				submitter.click();
			}

			$.ajax( {
				url: ideoFpI18n.ajaxUrl,
				method: 'POST',
				data: {
					action: ideoFpI18n.checkDuplicatesAction,
					nonce: ideoFpI18n.checkDuplicatesNonce,
					mapping: mapping
				},
				success: function( response ) {
					var duplicates = ( response.success && response.data && response.data.duplicates ) ? response.data.duplicates : [];
					restoreButton();
					if ( ! duplicates.length ) {
						resubmit();
						return;
					}
					openDupeModal( duplicates, function( choice, titlesToReplace ) {
						if ( ! choice ) return; // cancelled — stay on the mapping step
						dupeTitlesInput.value = JSON.stringify( 'replace' === choice ? titlesToReplace : [] );
						resubmit();
					} );
				},
				error: function() {
					// The check itself failed (network, etc.) — don't block the import on
					// that; submit as-is, the server still enforces its own checks.
					restoreButton();
					resubmit();
				}
			} );
		} );
	}

	// ── Confirm undo ─────────────────────────────────────────────────────────
	$( document ).on( 'submit', '#ifp-undo-form', function(e) {
		if ( ! window.confirm( ideoFpI18n.confirmUndo ) ) e.preventDefault();
	} );

	// ── Destination field → show/hide separator (import step 2) ────────────
	// Types ACF acceptant plusieurs valeurs séparées
	var MULTI_TYPES = [ 'checkbox', 'select', 'radio', 'relationship', 'post_object', 'gallery', 'taxonomy', 'user' ];

	function toggleSeparator( destSelect ) {
		var row        = destSelect.closest( 'tr' );
		if ( ! row ) return;
		var sepSelect  = row.querySelector( '.ifp-sep-select' );
		if ( ! sepSelect ) return;
		var chosen     = destSelect.options[ destSelect.selectedIndex ];
		var type       = chosen ? ( chosen.getAttribute( 'data-type' ) || '' ) : '';
		var isMulti    = MULTI_TYPES.indexOf( type ) !== -1;
		if ( isMulti ) {
			sepSelect.classList.remove( 'ifp-hidden' );
		} else {
			sepSelect.classList.add( 'ifp-hidden' );
		}
	}

	// Init on page load (in case a profile was loaded)
	document.querySelectorAll( '.ifp-select' ).forEach( function( destSelect ) {
		toggleSeparator( destSelect );
		destSelect.addEventListener( 'change', function() {
			toggleSeparator( destSelect );
		} );
	} );

	// Separator change → update preview + sync hidden input
	document.querySelectorAll( '.ifp-sep-select' ).forEach( function( sel ) {
		var row     = sel.closest( 'tr' );
		var preview = row ? row.querySelector( '.ifp-preview' ) : null;
		var hidden  = row ? row.querySelector( '.ifp-sep-hidden' ) : null;
		sel.addEventListener( 'change', function() {
			if ( preview ) {
				var raw = preview.getAttribute( 'data-raw' );
				if ( raw && raw !== '\u2014' ) {
					preview.textContent = raw.split( ',' ).map( function(v){ return v.trim(); } ).join( ',' === sel.value ? sel.value + ' ' : ' ' + sel.value + ' ' );
				}
			}
			if ( hidden ) hidden.value = sel.value;
		} );
	} );

	// ── Export: live editable preview table ───────────────────────────────
	// Replaces the old #ifp-export-columns list + Preview-modal combo: the
	// table itself IS the column configurator — drag/rename/checkbox/
	// separator live in its header cells. All edits are applied client-side
	// (no AJAX round-trip per edit); only the initial raw data is fetched
	// once on page load.
	( function() {
		var liveWrap = document.getElementById( 'ifp-live-table-wrap' );
		if ( ! liveWrap ) return; // present only on the Export tab

		var MULTI_TYPES = [ 'checkbox', 'select', 'gallery', 'relationship', 'post_object', 'taxonomy', 'Taxonomy' ];
		var SEP_CHARS    = [ ',', '|', '-', '/' ];

		var schema         = window.ideoFpColumnsSchema || [];
		var loadingEl      = document.getElementById( 'ifp-live-table-loading' );
		var innerEl        = document.getElementById( 'ifp-live-table-inner' );
		var hiddenColsInput = document.getElementById( 'ifp-export-columns-input' );
		var exportFormEl   = document.getElementById( 'ifp-export-form' );

		// Enter while renaming a column header must not implicitly submit (and
		// thus download) the export form — only an explicit click on "Download
		// file" should trigger the export. Blur instead, so Enter still "confirms"
		// the rename (the .ifp-live-th__rename change handler commits on blur).
		if ( exportFormEl ) {
			exportFormEl.addEventListener( 'keydown', function( e ) {
				if ( 'Enter' === e.key && e.target && 'INPUT' === e.target.tagName && 'text' === e.target.type ) {
					e.preventDefault();
					e.target.blur();
				}
			} );
		}

		// ── Zoom ─────────────────────────────────────────────────────────────
		// Scales #ifp-live-table-wrap itself (not its content) so the scrollbar
		// component's own scroll-range math (based on real, untransformed
		// layout sizes) stays correct at any zoom level with no extra
		// recalculation — and so its own transform-driven scroll/sticky-header
		// mechanism, which lives one level down inside this same element,
		// keeps composing correctly with the outer scale. `width` is widened
		// inversely to the scale (so that once scale() shrinks it back down it
		// still visually fills the container exactly, `width × scale` must
		// equal the container width at *every* instant, not just at the
		// start/end, or the table visibly over/undershoots its container
		// mid-transition) — animated by hand from a single interpolated value
		// driving both `width` and `transform` together every frame, instead
		// of two independent CSS transitions on px and scale-factor easing at
		// different rates.
		var ZOOM_DEFAULT  = 0.8;
		var zoomLevel     = ZOOM_DEFAULT;
		var appliedScale  = 1; // matches the wrap's un-transformed state until the initial setZoomScale() call below
		var ZOOM_MIN      = 0.5;
		var ZOOM_STEP     = 0.1;
		var ZOOM_DURATION = 200;
		var zoomAnimFrame = null;
		var zoomOutBtn    = document.getElementById( 'ifp-live-zoom-out' );
		var zoomInBtn     = document.getElementById( 'ifp-live-zoom-in' );

		liveWrap.style.transformOrigin = 'top left';

		function setZoomScale( s ) {
			// Same reasoning as the width: the wrap's own *layout* height stays
			// at its natural (unscaled) size regardless of its own transform,
			// which would otherwise leave a gap below the visually-shrunk
			// table for whatever follows it in the form. A negative bottom
			// margin pulls that reserved space back in to match the visual
			// (scaled) height exactly. offsetHeight is a layout measurement,
			// unaffected by this same element's own transform, so it's always
			// read as the true natural height regardless of current scale.
			var naturalHeight = liveWrap.offsetHeight;
			liveWrap.style.width        = 'calc(100% / ' + s + ')';
			liveWrap.style.transform    = 'scale(' + s + ')';
			liveWrap.style.marginBottom = ( -naturalHeight * ( 1 - s ) ) + 'px';
			appliedScale = s;
		}

		function applyZoom() {
			if ( zoomOutBtn ) zoomOutBtn.disabled = zoomLevel <= ZOOM_MIN + 0.001;
			if ( zoomInBtn )  zoomInBtn.disabled  = zoomLevel >= 1 - 0.001;

			if ( zoomAnimFrame ) cancelAnimationFrame( zoomAnimFrame );
			var startScale = appliedScale;
			var endScale   = zoomLevel;
			var startTime  = null;

			function tick( ts ) {
				if ( ! startTime ) startTime = ts;
				var t      = Math.min( 1, ( ts - startTime ) / ZOOM_DURATION );
				var eased  = 1 - Math.pow( 1 - t, 3 ); // ease-out cubic
				setZoomScale( startScale + ( endScale - startScale ) * eased );
				zoomAnimFrame = t < 1 ? requestAnimationFrame( tick ) : null;
			}
			zoomAnimFrame = requestAnimationFrame( tick );
		}
		if ( zoomOutBtn ) {
			zoomOutBtn.addEventListener( 'click', function() {
				zoomLevel = Math.max( ZOOM_MIN, Math.round( ( zoomLevel - ZOOM_STEP ) * 10 ) / 10 );
				applyZoom();
			} );
		}
		if ( zoomInBtn ) {
			zoomInBtn.addEventListener( 'click', function() {
				zoomLevel = Math.min( 1, Math.round( ( zoomLevel + ZOOM_STEP ) * 10 ) / 10 );
				applyZoom();
			} );
		}

		function schemaToModel( src ) {
			return src.map( function( col ) {
				return {
					key: col.key,
					label: col.label,
					type_label: col.type_label,
					included: ! col.default_off,
					sub_fields: ( col.sub_fields || [] ).map( function( sf ) {
						return { path_key: sf.path_key, label_path: sf.label_path.slice(), label: sf.label, depth: sf.depth };
					} )
				};
			} );
		}

		var model = schemaToModel( schema ); // live, mutable — reordered/renamed/filtered by the user

		var sepState = {}; // rootKey -> separator char, root-level leaf multi-value columns only
		model.forEach( function( col ) {
			if ( ! col.sub_fields.length && MULTI_TYPES.indexOf( col.type_label ) !== -1 ) sepState[ col.key ] = '|';
		} );

		// ── Flatten a (filtered, ordered) model into header-grid leaves ───────
		// A root column with no sub_fields is one leaf on its own; a root column
		// WITH sub_fields contributes one leaf per sub-field, its own label
		// becoming the shared top-level header text those leaves merge under.
		function flattenColumnsToLeaves( cols, onlyIncluded ) {
			var leaves = [];
			cols.forEach( function( col ) {
				if ( onlyIncluded && ! col.included ) return;
				if ( ! col.sub_fields.length ) {
					leaves.push( {
						label_path: [ col.label ],
						rootKey: col.key,
						pathKey: col.key,
						isMulti: MULTI_TYPES.indexOf( col.type_label ) !== -1
					} );
					return;
				}
				col.sub_fields.forEach( function( sf ) {
					var lp = sf.label_path.slice();
					lp[ 0 ]            = col.label; // root label may have been renamed
					lp[ lp.length - 1 ] = sf.label;  // leaf label may have been renamed
					leaves.push( { label_path: lp, rootKey: col.key, pathKey: sf.path_key, isMulti: false } );
				} );
			} );
			return leaves;
		}

		// Stable natural-order leaves (pristine schema, nothing excluded/reordered)
		// — used only to map a leaf's identity to its index in the raw row data,
		// which the server always returns in that same natural order.
		var naturalLeaves = flattenColumnsToLeaves( schemaToModel( schema ), false );
		var leafRowIndex  = {};
		naturalLeaves.forEach( function( l, i ) { leafRowIndex[ l.pathKey ] = i; } );

		// ── Header-grid builder — JS port of build_header_structure() +
		// same_parent() from includes/class-spreadsheet-exporter.php ──────────
		function sameParent( a, b, r ) {
			for ( var i = 0; i < r; i++ ) {
				var av = a.label_path[ i ] !== undefined ? a.label_path[ i ] : null;
				var bv = b.label_path[ i ] !== undefined ? b.label_path[ i ] : null;
				if ( av !== bv ) return false;
			}
			return true;
		}

		function buildHeaderGrid( leaves ) {
			if ( ! leaves.length ) return { rows: [], depth: 0 };

			var depth = 1;
			leaves.forEach( function( l ) { depth = Math.max( depth, l.label_path.length ); } );
			var colCount = leaves.length;

			var headerRows = [];
			for ( var r = 0; r < depth; r++ ) headerRows.push( new Array( colCount ).fill( '' ) );
			leaves.forEach( function( leaf, ci ) {
				for ( var r = 0; r < leaf.label_path.length; r++ ) headerRows[ r ][ ci ] = leaf.label_path[ r ];
			} );

			var merges = [];
			for ( var r = 0; r < depth; r++ ) {
				var c = 0;
				while ( c < colCount ) {
					var label = headerRows[ r ][ c ];
					if ( '' === label ) { c++; continue; }
					var runEnd = c;
					while ( runEnd + 1 < colCount && headerRows[ r ][ runEnd + 1 ] === label && sameParent( leaves[ c ], leaves[ runEnd + 1 ], r ) ) {
						runEnd++;
					}
					if ( runEnd > c ) {
						merges.push( { fc: c, fr: r, tc: runEnd, tr: r } );
						for ( var mc = c + 1; mc <= runEnd; mc++ ) headerRows[ r ][ mc ] = '';
					}
					c = runEnd + 1;
				}
			}
			leaves.forEach( function( leaf, ci ) {
				var lpDepth = leaf.label_path.length;
				if ( lpDepth < depth ) merges.push( { fc: ci, fr: lpDepth - 1, tc: ci, tr: depth - 1 } );
			} );

			var mergeMap = {};
			merges.forEach( function( m ) {
				mergeMap[ m.fr + ':' + m.fc ] = { colspan: m.tc - m.fc + 1, rowspan: m.tr - m.fr + 1 };
				for ( var r = m.fr; r <= m.tr; r++ ) {
					for ( var c = m.fc; c <= m.tc; c++ ) {
						if ( r !== m.fr || c !== m.fc ) mergeMap[ r + ':' + c ] = 'skip';
					}
				}
			} );

			var rows = [];
			for ( var r = 0; r < depth; r++ ) {
				var rowCells = [];
				for ( var c = 0; c < colCount; c++ ) {
					var mm = mergeMap[ r + ':' + c ];
					if ( 'skip' === mm ) continue;
					var colspan = 1, rowspan = 1;
					if ( mm && 'object' === typeof mm ) { colspan = mm.colspan; rowspan = mm.rowspan; }
					rowCells.push( { label: headerRows[ r ][ c ], colspan: colspan, rowspan: rowspan, colIdx: c, row: r } );
				}
				rows.push( rowCells );
			}
			return { rows: rows, depth: depth };
		}

		// ── Data + rendering ───────────────────────────────────────────────────
		var rawRows       = [];
		var postRowCounts = []; // rows-per-post, for zebra striping that bands whole posts, not raw rows

		function findRootCol( key ) {
			for ( var i = 0; i < model.length; i++ ) if ( model[ i ].key === key ) return model[ i ];
			return null;
		}
		function findSubField( rootKey, pathKey ) {
			var root = findRootCol( rootKey );
			if ( ! root ) return null;
			for ( var i = 0; i < root.sub_fields.length; i++ ) if ( root.sub_fields[ i ].path_key === pathKey ) return root.sub_fields[ i ];
			return null;
		}

		function escapeHtml( s ) {
			return $( '<span>' ).text( null == s ? '' : s ).html();
		}

		function cellValueGetter( leaf ) {
			var idx = leafRowIndex[ leaf.pathKey ];
			return function( rowRaw ) {
				var v = undefined !== idx ? rowRaw[ idx ] : '';
				if ( Array.isArray( v ) ) {
					var sep  = sepState[ leaf.rootKey ] || '|';
					var glue = ',' === sep ? sep + ' ' : ' ' + sep + ' ';
					return v.join( glue );
				}
				return v || '';
			};
		}

		function renderTable() {
			var ordered = model.filter( function( c ) { return c.included; } );
			var leaves  = flattenColumnsToLeaves( ordered, true );

			if ( ! leaves.length ) {
				innerEl.innerHTML = '<p class="ifp-live-table-empty">' + escapeHtml( ideoFpI18n.liveTableEmpty || 'No columns selected — bring one back from the list below.' ) + '</p>';
				renderExcludedColumns();
				syncHiddenInputs();
				setZoomScale( zoomLevel );
				return;
			}

			var grid = buildHeaderGrid( leaves );
			var html = '<table id="ifp-live-table"><thead>';

			grid.rows.forEach( function( rowCells ) {
				html += '<tr class="ideo-pimp-scrollbar__sticky-top">';
				rowCells.forEach( function( cell ) {
					var leaf    = leaves[ cell.colIdx ];
					var isRoot  = 0 === cell.row;
					var isFinal = cell.row === leaf.label_path.length - 1;
					var attrs   = '';
					if ( cell.colspan > 1 ) attrs += ' colspan="' + cell.colspan + '"';
					if ( cell.rowspan > 1 ) attrs += ' rowspan="' + cell.rowspan + '"';

					html += '<th' + attrs + ' class="ifp-live-th' + ( isRoot ? ' ifp-live-th--root' : '' ) + '" data-root-key="' + escapeHtml( leaf.rootKey ) + '">';

					if ( isRoot ) {
						var rootCol  = findRootCol( leaf.rootKey );
						var isLocked = '__title__' === leaf.rootKey;
						html += '<div class="ifp-live-th__inner ifp-live-th__inner--root">';
						html += '  <div class="ifp-live-th__controls">';
						html += '    <span class="ifp-live-th__handle" title="' + escapeHtml( ideoFpI18n.dragToReorder || 'Drag to reorder' ) + '">⠿</span>';
						html += '    <button type="button" class="ifp-live-th__mover" data-dir="-1" title="' + escapeHtml( ideoFpI18n.moveLeft || 'Move left' ) + '">‹</button>';
						html += '    <button type="button" class="ifp-live-th__mover" data-dir="1" title="' + escapeHtml( ideoFpI18n.moveRight || 'Move right' ) + '">›</button>';
						html += '    <button type="button" class="ifp-live-th__mover ifp-live-th__remove"' + ( isLocked ? ' disabled title="' + escapeHtml( ideoFpI18n.requiredColumn || 'Required column' ) + '"' : ' title="' + escapeHtml( ideoFpI18n.removeColumn || 'Remove column' ) + '"' ) + ' data-key="' + escapeHtml( leaf.rootKey ) + '">✕</button>';
						html += '  </div>';
						html += '  <div class="ifp-live-th__namerow">';
						html += '    <input type="text" class="ifp-live-th__rename" data-scope="root" data-key="' + escapeHtml( leaf.rootKey ) + '" value="' + escapeHtml( rootCol ? rootCol.label : cell.label ) + '">';
						if ( leaf.isMulti && 1 === leaf.label_path.length ) {
							html += '<select class="ifp-live-th__sep" data-key="' + escapeHtml( leaf.rootKey ) + '" title="' + escapeHtml( ideoFpI18n.sepTitle || 'Value separator' ) + '">';
							SEP_CHARS.forEach( function( s ) {
								html += '<option value="' + s + '"' + ( ( sepState[ leaf.rootKey ] || '|' ) === s ? ' selected' : '' ) + '>' + s + '</option>';
							} );
							html += '</select>';
						}
						html += '  </div>';
						html += '</div>';
					} else if ( isFinal ) {
						var sf = findSubField( leaf.rootKey, leaf.pathKey );
						html += '<div class="ifp-live-th__inner"><input type="text" class="ifp-live-th__rename" data-scope="sub" data-root-key="' + escapeHtml( leaf.rootKey ) + '" data-path-key="' + escapeHtml( leaf.pathKey ) + '" value="' + escapeHtml( sf ? sf.label : cell.label ) + '"></div>';
					} else {
						html += '<div class="ifp-live-th__inner"><span class="ifp-live-th__label">' + escapeHtml( cell.label ) + '</span></div>';
					}

					html += '</th>';
				} );
				html += '</tr>';
			} );
			html += '</thead><tbody>';

			// Zebra-stripe whole posts, not raw rows — a post expanded into several
			// repeater rows must keep one consistent background across all of them.
			var rowBg = [];
			if ( postRowCounts.length ) {
				postRowCounts.forEach( function( count, postIdx ) {
					var bg = ( postIdx % 2 === 1 ) ? ( window.ideoFpLiveTableLightColor || '' ) : '';
					for ( var i = 0; i < count; i++ ) rowBg.push( bg );
				} );
			} else {
				rawRows.forEach( function( _, i ) { rowBg.push( i % 2 === 1 ? ( window.ideoFpLiveTableLightColor || '' ) : '' ); } );
			}

			var getters = leaves.map( cellValueGetter );
			rawRows.forEach( function( rowRaw, ri ) {
				var values = getters.map( function( get ) { return get( rowRaw ); } );
				// Raw rows are fetched once, up front, with every field —
				// a post with 2 phone numbers occupies 2 raw rows regardless
				// of whether Phones ends up excluded. Once excluded, that
				// second row has nothing left to show on any visible column
				// and would otherwise render as a bare blank <tr>. rowBg
				// stays indexed by the original ri, so skipping some rows
				// doesn't shift the per-post zebra colour of the ones kept.
				if ( values.every( function( v ) { return '' === v; } ) ) return;
				var bg = rowBg[ ri ] ? ' style="background:' + rowBg[ ri ] + ';"' : '';
				html += '<tr' + bg + '>';
				values.forEach( function( v, gi ) {
					var display = v.length > 80 ? v.substring( 0, 77 ) + '…' : v;
					html += '<td data-root-key="' + escapeHtml( leaves[ gi ].rootKey ) + '" title="' + escapeHtml( v ) + '">' + escapeHtml( display ).replace( /\n/g, '<br>' ) + '</td>';
				} );
				html += '</tr>';
			} );
			html += '</tbody></table>';

			innerEl.innerHTML = html;
			renderExcludedColumns();
			bindHeaderInteractions();
			syncHiddenInputs();
			// Row count (and so the wrap's natural height) varies with the
			// active sheet/filters — the zoom scale's compensating negative
			// margin (see setZoomScale) must be recomputed every render or a
			// stale margin from a taller previous render pulls whatever
			// follows the table (the sheet tabs) up over its bottom rows.
			setZoomScale( zoomLevel );
		}

		// ── Header interactions: checkbox, rename, separator, movers, drag ────
		function bindHeaderInteractions() {
			var table = document.getElementById( 'ifp-live-table' );
			if ( ! table ) return;

			table.querySelectorAll( '.ifp-live-th__remove' ).forEach( function( btn ) {
				btn.addEventListener( 'click', function() {
					var key = btn.getAttribute( 'data-key' );
					if ( '__title__' === key ) return; // required column
					var col = findRootCol( key );
					if ( ! col ) return;
					// Rise-and-fade the column's own cells out first (toward the
					// excluded-columns block above the table, where it's about
					// to reappear as a pill), then collapse the gap with the
					// same FLIP slide used for reordering — an instant DOM
					// removal otherwise reads as an abrupt pop, especially for
					// wide columns.
					var cells = document.querySelectorAll( '#ifp-live-table th[data-root-key="' + key + '"], #ifp-live-table td[data-root-key="' + key + '"]' );
					// A plain translateY (no scale) reads as the whole column
					// heading up toward the excluded-columns block above the
					// table, whatever its width — a shrink instead made every
					// cell of a multi-sub-column block visibly scale toward
					// its own center, which looked like separate pieces
					// rather than one column leaving as a unit.
					cells.forEach( function( cell ) {
						cell.style.transition    = 'opacity .2s ease-in, transform .2s ease-in';
						cell.style.opacity       = '0';
						cell.style.transform     = 'translateY(-40px)';
						cell.style.pointerEvents = 'none';
					} );
					setTimeout( function() {
						col.included = false;
						animateReorder( function() { renderTable(); } );
					}, 200 );
				} );
			} );

			table.querySelectorAll( '.ifp-live-th__rename' ).forEach( function( input ) {
				input.addEventListener( 'change', function() {
					if ( 'root' === input.getAttribute( 'data-scope' ) ) {
						var col = findRootCol( input.getAttribute( 'data-key' ) );
						if ( col ) col.label = input.value.trim() || col.label;
					} else {
						var sf = findSubField( input.getAttribute( 'data-root-key' ), input.getAttribute( 'data-path-key' ) );
						if ( sf ) sf.label = input.value.trim() || sf.label;
					}
					renderTable();
				} );
			} );

			table.querySelectorAll( '.ifp-live-th__sep' ).forEach( function( select ) {
				select.addEventListener( 'change', function() {
					sepState[ select.getAttribute( 'data-key' ) ] = select.value;
					renderTable();
				} );
			} );

			// :not(.ifp-live-th__remove) — the remove button reuses this class
			// only for its visual look, it has no data-dir and must not also
			// get wired up as a mover (it used to, silently corrupting the
			// model with a NaN swap index on every removal).
			table.querySelectorAll( '.ifp-live-th__mover:not(.ifp-live-th__remove)' ).forEach( function( btn ) {
				btn.addEventListener( 'click', function() {
					var th = btn.closest( '.ifp-live-th--root' );
					moveRootColumn( th.getAttribute( 'data-root-key' ), parseInt( btn.getAttribute( 'data-dir' ), 10 ) );
				} );
			} );

			bindHeaderDrag( table );
		}

		// FLIP (First-Last-Invert-Play): snapshot every root header cell's
		// position, let `fn` mutate the model and re-render, then animate each
		// cell from its old position to its new one — makes a full table
		// rebuild (the only practical way to reorder <table> columns, see the
		// note on reorderModel/renderTable above) still read as a smooth move
		// instead of an instant jump. Only root columns are reorderable, so a
		// root's whole column — itself, its sub-field header cells, and every
		// body <td> below it — always shifts as one rigid block: the root's
		// own dx is reused for every cell sharing its data-root-key instead
		// of tracking each cell separately.
		function animateReorder( fn ) {
			var oldRects = {};
			document.querySelectorAll( '.ifp-live-th--root' ).forEach( function( th ) {
				oldRects[ th.getAttribute( 'data-root-key' ) ] = th.getBoundingClientRect();
			} );

			fn();

			requestAnimationFrame( function() {
				document.querySelectorAll( '.ifp-live-th--root' ).forEach( function( th ) {
					var key = th.getAttribute( 'data-root-key' );
					var old = oldRects[ key ];
					if ( ! old ) return;
					var neu = th.getBoundingClientRect();
					var dx  = old.left - neu.left;
					if ( ! dx ) return;
					// dx is a real screen-pixel delta (from getBoundingClientRect),
					// but the transform below is applied to a cell living inside
					// the zoomable #ifp-live-table-wrap — dividing by the current
					// zoom level converts it back to that element's local,
					// pre-ancestor-scale coordinate space so it renders at the
					// intended dx once the outer zoom is composed on top.
					var localDx = dx / appliedScale;
					// Cell selector only (th/td) — the sub-field rename <input>
					// also carries data-root-key for its own purposes, and
					// matching it too would transform it a second time on top
					// of its already-transformed parent <th>.
					var cells = document.querySelectorAll( '#ifp-live-table th[data-root-key="' + key + '"], #ifp-live-table td[data-root-key="' + key + '"]' );
					cells.forEach( function( cell ) {
						cell.style.transition = 'none';
						cell.style.transform  = 'translateX(' + localDx + 'px)';
					} );
					requestAnimationFrame( function() {
						cells.forEach( function( cell ) {
							cell.style.transition = 'transform .5s ease';
							cell.style.transform  = 'translateX(0)';
							cell.addEventListener( 'transitionend', function handler() {
								cell.style.transition = '';
								cell.style.transform  = '';
								cell.removeEventListener( 'transitionend', handler );
							} );
						} );
					} );
				} );
			} );
		}

		function moveRootColumn( key, dir ) {
			var included = model.filter( function( c ) { return c.included; } );
			var idx = -1;
			for ( var i = 0; i < included.length; i++ ) if ( included[ i ].key === key ) { idx = i; break; }
			if ( -1 === idx ) return;
			var swapWith = idx + dir;
			if ( swapWith < 0 || swapWith >= included.length ) return;
			animateReorder( function() {
				var a = model.indexOf( included[ idx ] );
				var b = model.indexOf( included[ swapWith ] );
				var tmp = model[ a ]; model[ a ] = model[ b ]; model[ b ] = tmp;
				renderTable();
			} );
		}

		function reorderModel( draggingKey, targetKey, after ) {
			var draggingCol = findRootCol( draggingKey );
			var targetCol   = findRootCol( targetKey );
			if ( ! draggingCol || ! targetCol ) return;
			model.splice( model.indexOf( draggingCol ), 1 );
			var toIdx = model.indexOf( targetCol );
			model.splice( after ? toIdx + 1 : toIdx, 0, draggingCol );
		}

		// Dropping on the near half of the column's own immediate neighbour
		// (the right half of the one just before it, or the left half of the
		// one just after it) re-inserts it exactly where it already was — a
		// no-op reorder. Simulates reorderModel()'s splice math without
		// mutating `model`, so the drop indicator can stay hidden through
		// that dead zone instead of flashing on for a drop that visibly
		// changes nothing.
		function wouldReorder( draggingKey, targetKey, after ) {
			var draggingCol = findRootCol( draggingKey );
			var targetCol   = findRootCol( targetKey );
			if ( ! draggingCol || ! targetCol ) return false;
			var fromIdx = model.indexOf( draggingCol );
			var toIdx   = model.indexOf( targetCol ) + ( after ? 1 : 0 );
			if ( toIdx > fromIdx ) toIdx -= 1; // account for draggingCol's own removal
			return toIdx !== fromIdx;
		}

		// The whole column (header + every row's cell) is dragged as one visual
		// block — via a shared translateX() transform on the live cells
		// themselves, not a fake ghost — so the header never appears to move
		// without its rows. The model is mutated and the table re-rendered
		// once on drop, not on every mousemove — reordering a live <table>'s
		// columns in place is the hard part this sidesteps.
		function bindHeaderDrag( table ) {
			var dragging = null, columnCells = null, grabOffsetX = 0, appliedDx = 0, autoScrollTimer = null;
			var dropTargetKey = null, dropAfter = false, dropIndicator = null, columnShadow = null;

			// The drop marker is a single fixed-position bar we move ourselves,
			// not a per-cell CSS effect — a per-cell box-shadow/pseudo-element
			// always loses to that same cell's own border in paint order (shows
			// as broken segments at every row boundary), and applying it to
			// every cell of a root's group (needed so the mark reaches the full
			// table height) also drew a bar at each internal sub-field boundary,
			// where dropping isn't actually possible (only root columns reorder).
			function showDropIndicator( x ) {
				if ( ! dropIndicator ) {
					dropIndicator = document.createElement( 'div' );
					dropIndicator.className = 'ifp-live-drop-indicator';
					document.body.appendChild( dropIndicator );
				}
				var tRect = table.getBoundingClientRect();
				// A column mid-autoscroll can be partially (or fully) clipped by
				// the wrap's own overflow:hidden while its true, unclipped rect
				// still extends further left/right than what's visible — clamp to
				// the wrap's own bounds so the bar (a position:fixed element, so
				// not itself clipped by that overflow) never renders past the
				// table and over whatever sits beside it (e.g. the WP admin menu).
				var wrapRect = liveWrap.getBoundingClientRect();
				x = Math.max( wrapRect.left, Math.min( wrapRect.right, x ) );
				dropIndicator.style.left   = ( x - 2 ) + 'px';
				dropIndicator.style.top    = tRect.top + 'px';
				dropIndicator.style.height = tRect.height + 'px';
			}

			function removeDropIndicator() {
				if ( dropIndicator && dropIndicator.parentNode ) dropIndicator.parentNode.removeChild( dropIndicator );
				dropIndicator = null;
			}

			// One shadow around the whole moving block, not one per cell — same
			// reasoning as the drop indicator above: a box-shadow set directly
			// on every dragged cell shows as a broken strip at each row's own
			// edge instead of a single outline. A transparent-background overlay
			// sized to the union of all dragged cells' rects gives one clean
			// shadow around the full column instead; it doesn't need to paint
			// above the cells' own text — the shadow only extends outward from
			// its (invisible) box, so the real cells stay legible underneath.
			function updateColumnShadow() {
				var top = Infinity, bottom = -Infinity, left = Infinity, right = -Infinity;
				columnCells.forEach( function( cell ) {
					var r = cell.getBoundingClientRect();
					if ( r.top    < top    ) top    = r.top;
					if ( r.bottom > bottom ) bottom = r.bottom;
					if ( r.left   < left   ) left   = r.left;
					if ( r.right  > right  ) right  = r.right;
				} );
				if ( ! columnShadow ) {
					columnShadow = document.createElement( 'div' );
					columnShadow.className = 'ifp-live-col-shadow';
					document.body.appendChild( columnShadow );
				}
				columnShadow.style.left   = left + 'px';
				columnShadow.style.top    = top + 'px';
				columnShadow.style.width  = ( right - left ) + 'px';
				columnShadow.style.height = ( bottom - top ) + 'px';
			}

			function removeColumnShadow() {
				if ( columnShadow && columnShadow.parentNode ) columnShadow.parentNode.removeChild( columnShadow );
				columnShadow = null;
			}

			function onMove( e ) {
				if ( ! dragging ) return;
				// rect.left already includes whatever appliedDx we set last
				// frame, so subtracting it back out gives the column's true
				// (untransformed) position — works whether that position moved
				// because the mouse did, or because the live table's own
				// transform-based scrollbar panned underneath it.
				var rect        = dragging.getBoundingClientRect();
				var naturalLeft = rect.left - appliedDx;
				appliedDx = ( e.clientX - grabOffsetX ) - naturalLeft;
				columnCells.forEach( function( cell ) {
					cell.style.transform = 'translateX(' + appliedDx + 'px)';
				} );
				updateColumnShadow();

				var wrapRect = liveWrap.getBoundingClientRect();
				var margin   = 50;
				clearInterval( autoScrollTimer );
				// Speed ramps up with how far the cursor pushes into the edge
				// margin (4px/tick right at the edge, up to 14px/tick at the
				// margin's inner boundary) instead of jumping straight to a
				// fixed fast speed — much easier to control near the border.
				var pastLeft  = wrapRect.left + margin - e.clientX;
				var pastRight = e.clientX - ( wrapRect.right - margin );
				if ( pastLeft > 0 ) {
					var speedL = 4 + Math.min( 1, pastLeft / margin ) * 10;
					autoScrollTimer = setInterval( function() {
						liveWrap.dispatchEvent( new WheelEvent( 'wheel', { deltaX: -speedL, deltaY: 0, bubbles: true, cancelable: true } ) );
					}, 16 );
				} else if ( pastRight > 0 ) {
					var speedR = 4 + Math.min( 1, pastRight / margin ) * 10;
					autoScrollTimer = setInterval( function() {
						liveWrap.dispatchEvent( new WheelEvent( 'wheel', { deltaX: speedR, deltaY: 0, bubbles: true, cancelable: true } ) );
					}, 16 );
				}

				var target = null, after = false;
				table.querySelectorAll( '.ifp-live-th--root' ).forEach( function( th ) {
					if ( th === dragging ) return;
					var r = th.getBoundingClientRect();
					if ( e.clientX >= r.left && e.clientX <= r.right ) {
						target = th;
						after  = e.clientX > r.left + r.width / 2;
					}
				} );
				var draggingKey = dragging.getAttribute( 'data-root-key' );
				if ( target && wouldReorder( draggingKey, target.getAttribute( 'data-root-key' ), after ) ) {
					var r2 = target.getBoundingClientRect();
					showDropIndicator( after ? r2.right : r2.left );
					dropTargetKey = target.getAttribute( 'data-root-key' );
					dropAfter     = after;
				} else {
					removeDropIndicator();
					dropTargetKey = null;
				}
			}

			function onKeyDown( e ) {
				if ( 'Escape' !== e.key ) return;
				dropTargetKey = null; // drop where it started — no reorder
				onUp();
			}

			function onContextMenu( e ) {
				e.preventDefault(); // right-click also cancels — don't let the OS menu open too
				dropTargetKey = null;
				onUp();
			}

			function onUp() {
				clearInterval( autoScrollTimer );
				var draggingKey = dragging ? dragging.getAttribute( 'data-root-key' ) : null;
				if ( columnCells ) columnCells.forEach( function( cell ) {
					cell.classList.remove( 'ifp-live-col--dragging' );
					cell.style.transform  = '';
					cell.style.background = '';
				} );
				if ( dragging ) dragging.classList.remove( 'ifp-live-th--dragging' );
				removeDropIndicator();
				removeColumnShadow();
				document.removeEventListener( 'mousemove', onMove );
				document.removeEventListener( 'mouseup', onUp );
				document.removeEventListener( 'keydown', onKeyDown );
				document.removeEventListener( 'contextmenu', onContextMenu );
				var willReorder = draggingKey && dropTargetKey && draggingKey !== dropTargetKey;
				dragging = null; columnCells = null; appliedDx = 0;
				var targetKey = dropTargetKey, after = dropAfter;
				dropTargetKey = null;
				if ( willReorder ) {
					animateReorder( function() {
						reorderModel( draggingKey, targetKey, after );
						renderTable();
					} );
				} else {
					renderTable();
				}
			}

			table.querySelectorAll( '.ifp-live-th__controls' ).forEach( function( handle ) {
				handle.addEventListener( 'mousedown', function( e ) {
					if ( e.button !== 0 ) return;
					if ( e.target.closest( '.ifp-live-th__mover' ) ) return;
					var th = handle.closest( '.ifp-live-th--root' );
					if ( ! th ) return;
					e.preventDefault();
					dragging = th;
					th.classList.add( 'ifp-live-th--dragging' );
					var key = th.getAttribute( 'data-root-key' );
					// Every cell sharing this root key — header and every row's
					// td, plus any nested sub-field cells (Hierarchical mode) —
					// so the dragged block reads as the full column, not just
					// its header.
					columnCells = Array.prototype.slice.call(
						table.querySelectorAll( 'th[data-root-key="' + key + '"], td[data-root-key="' + key + '"]' )
					);
					columnCells.forEach( function( cell ) {
						cell.classList.add( 'ifp-live-col--dragging' );
						// td cells have no background of their own — only their
						// <tr> does (zebra-striping, applied per post so a
						// repeater's rows share one colour; see renderTable()).
						// Left as-is, a dragged td is a transparent hole that the
						// column it slides over shows straight through, mixing
						// both columns' text together. th needs no such fix — it
						// already carries its own opaque background via
						// .ifp-live-th, untouched here.
						if ( 'TD' === cell.tagName ) {
							cell.style.background = cell.parentElement.style.background || 'var(--bg)';
						}
					} );
					var rect = th.getBoundingClientRect();
					grabOffsetX = e.clientX - rect.left;
					appliedDx = 0;
					document.addEventListener( 'mousemove', onMove );
					document.addEventListener( 'mouseup', onUp );
					document.addEventListener( 'keydown', onKeyDown );
					document.addEventListener( 'contextmenu', onContextMenu );
				} );
			} );
		}

		// ── Excluded columns — always-visible block, click a pill to bring a
		// column back (replaces the earlier "+ Column" popover: the excluded
		// list is now visible at a glance instead of hidden behind a click).
		// Stays in the DOM even with nothing excluded (dimmed via the --empty
		// modifier) instead of disappearing/reappearing, so the toolbar layout
		// doesn't jump around as columns get excluded/restored.
		function renderExcludedColumns() {
			var excluded = model.filter( function( c ) { return ! c.included; } );
			var block = document.getElementById( 'ifp-live-excluded' );
			// Every render rebuilds all pills from scratch (full innerHTML
			// replace), so "new" has to be tracked explicitly — otherwise
			// every pre-existing pill would replay the pop-in animation too
			// each time just one more column is excluded.
			var previousKeys = block
				? Array.prototype.map.call( block.querySelectorAll( '.ifp-live-excluded__item' ), function( b ) { return b.getAttribute( 'data-key' ); } )
				: [];
			if ( ! block ) {
				block = document.createElement( 'div' );
				block.className = 'ifp-sheet-mode ifp-live-excluded';
				block.id = 'ifp-live-excluded';
				var toolbarLeft = document.getElementById( 'ifp-live-table-toolbar__left' );
				if ( toolbarLeft ) toolbarLeft.appendChild( block );
			}
			block.classList.toggle( 'ifp-live-excluded--empty', ! excluded.length );
			var html = '<p class="ifp-sheet-mode__desc">' + escapeHtml( ideoFpI18n.excludedColumns || 'Non-exported columns (click to bring back)' ) + '</p>';
			html += '<div class="ifp-sheet-mode__row">';
			excluded.forEach( function( col ) {
				var isNew = -1 === previousKeys.indexOf( col.key );
				var isId  = '__post_id__' === col.key;
				html += '<button type="button" class="ifp-live-excluded__item' + ( isNew ? ' ifp-live-excluded__item--pop' : '' ) + '"'
					+ ( isId ? ' title="' + escapeHtml( ideoFpI18n.idColumnTooltip || 'Unique identifier automatically generated by WordPress' ) + '"' : '' )
					+ ' data-key="' + escapeHtml( col.key ) + '">' + escapeHtml( col.label ) + '</button>';
			} );
			html += '</div>';
			block.innerHTML = html;

			block.querySelectorAll( '.ifp-live-excluded__item' ).forEach( function( btn ) {
				btn.addEventListener( 'click', function() {
					var col = findRootCol( btn.getAttribute( 'data-key' ) );
					if ( col ) col.included = true;
					animateReorder( function() { renderTable(); } );
					// Column can land anywhere in the model's order — scroll it
					// into view and flash it briefly so bringing it back is
					// visibly confirmed instead of leaving the user to hunt
					// for it off-screen.
					if ( col ) {
						var newCells = document.querySelectorAll( '#ifp-live-table [data-root-key="' + col.key + '"]' );
						var newTh    = document.querySelector( '#ifp-live-table .ifp-live-th--root[data-root-key="' + col.key + '"]' );
						if ( newTh && window.IdeoPimpScrollbar ) IdeoPimpScrollbar.scrollToElement( liveWrap, newTh, { vertical: false } );
						newCells.forEach( function( cell ) { cell.classList.add( 'ifp-live-col--flash' ); } );
						setTimeout( function() {
							newCells.forEach( function( cell ) { cell.classList.remove( 'ifp-live-col--flash' ); } );
						}, 1000 );
					}
				} );
			} );
		}

		// ── Keep the real submit in sync with the live model ───────────────────
		function syncHiddenInputs() {
			var cols = model.filter( function( c ) { return c.included; } ).map( function( c ) {
				var subLabels = {};
				c.sub_fields.forEach( function( sf ) { subLabels[ sf.path_key ] = sf.label; } );
				return { key: c.key, label: c.label, sub_labels: subLabels };
			} );
			if ( hiddenColsInput ) hiddenColsInput.value = JSON.stringify( cols );

			if ( exportFormEl ) {
				exportFormEl.querySelectorAll( '.ifp-live-sep-hidden' ).forEach( function( el ) { el.remove(); } );
				Object.keys( sepState ).forEach( function( key ) {
					var inp = document.createElement( 'input' );
					inp.type = 'hidden';
					inp.className = 'ifp-live-sep-hidden';
					inp.name = 'sep[' + key + ']';
					inp.value = sepState[ key ];
					exportFormEl.appendChild( inp );
				} );
			}
		}

		// ── Click-and-drag anywhere to pan (except the interactive header
		// controls) — simulated by feeding mouse-move deltas to the scrollbar
		// component's own wheel handler, so it gets the same inertia as a real
		// wheel/trackpad scroll for free, no new scroll API needed.
		function bindPanToScroll() {
			var EXCLUDE = '.ifp-live-th__controls, .ifp-live-th__rename, .ifp-live-th__sep, a, button, input, select';
			var panning = false, lastX = 0, lastY = 0;

			liveWrap.addEventListener( 'mousedown', function( e ) {
				if ( e.button !== 0 ) return;
				if ( e.target.closest( EXCLUDE ) ) return;
				panning = true;
				lastX = e.clientX; lastY = e.clientY;
				liveWrap.classList.add( 'ifp-live-table-wrap--panning' );
				e.preventDefault();
			} );
			document.addEventListener( 'mousemove', function( e ) {
				if ( ! panning ) return;
				e.preventDefault();
				var dx = e.clientX - lastX, dy = e.clientY - lastY;
				lastX = e.clientX; lastY = e.clientY;
				if ( dx ) liveWrap.dispatchEvent( new WheelEvent( 'wheel', { deltaX: -dx, deltaY: 0, bubbles: true, cancelable: true } ) );
				if ( dy ) liveWrap.dispatchEvent( new WheelEvent( 'wheel', { deltaX: 0, deltaY: -dy, bubbles: true, cancelable: true } ) );
			} );
			document.addEventListener( 'mouseup', function() {
				if ( ! panning ) return;
				panning = false;
				liveWrap.classList.remove( 'ifp-live-table-wrap--panning' );
			} );
		}
		bindPanToScroll();

		// ── Load the full natural leaf set, raw, once on page load ────────────
		function loadSheetsData( onDone ) {
			$.ajax( {
				url: ideoFpI18n.ajaxUrl,
				method: 'POST',
				data: {
					action: ideoFpI18n.previewAction,
					nonce: ideoFpI18n.previewNonce,
					post_type: ideoFpI18n.postType,
					export_columns: '',
					sep: {},
					raw: 1
				},
				success: function( response ) {
					if ( ! response.success || ! response.data ) {
						if ( loadingEl ) loadingEl.textContent = 'Error loading preview.';
						return;
					}
					var d = response.data;
					rawRows       = d.rows || [];
					postRowCounts = d.post_row_counts || [];
					window.ideoFpLiveTableColor      = d.color || '';
					window.ideoFpLiveTableLightColor = d.light_color || '';
					if ( loadingEl ) loadingEl.style.display = 'none';
					renderTable();
					if ( onDone ) onDone();
				},
				error: function() {
					if ( loadingEl ) loadingEl.textContent = 'Request failed.';
				}
			} );
		}

		if ( window.IdeoPimpScrollbar ) IdeoPimpScrollbar.initOne( liveWrap );

		loadSheetsData( function() {
			setZoomScale( zoomLevel );
			if ( zoomOutBtn ) zoomOutBtn.disabled = zoomLevel <= ZOOM_MIN + 0.001;
			if ( zoomInBtn )  zoomInBtn.disabled  = zoomLevel >= 1 - 0.001;
		} );
	} )();

} );

// ── Settings page: pulse the Save button when the form has unsaved changes ──
( function() {
	var dirty       = false;
	var form        = document.getElementById( 'ifp-settings-form' );
	var saveBtn     = document.getElementById( 'ifp-save-btn' );
	var footer      = document.querySelector( '.ifp-shell__footer' );
	if ( ! form ) return;

	// Snapshot the initial form state to detect *real* changes only.
	function serializeForm() {
		var fd = new FormData( form );
		var pairs = [];
		fd.forEach( function( value, key ) { pairs.push( key + '=' + value ); } );
		return pairs.sort().join( '&' );
	}
	var initialState = serializeForm();

	// Only pin the Save button to the viewport corner when its normal spot
	// in the footer has scrolled out of view — if the user can already see
	// it there, jumping it into a floating position would just be noise.
	var footerVisible = true;
	var floating      = false;
	function updateFloating() {
		if ( ! saveBtn ) return;
		var shouldFloat = dirty && ! footerVisible;
		if ( shouldFloat === floating ) return;
		floating = shouldFloat;
		if ( floating ) {
			// Toggling --floating and --floating-hidden together wasn't a
			// reliable transition start: the button is also switching to
			// position:fixed in that same style pass, and browsers don't
			// consistently treat that as "a change from a rendered frame" to
			// animate from — it just jumps straight to the end value. Explicit
			// inline styles (with transitions off) pin down an unambiguous
			// "from" state instead; clearing them on the next frame is what
			// actually triggers the transition to the class's resting values.
			saveBtn.classList.add( 'ifp-save-btn--floating' );
			saveBtn.style.transition = 'none';
			saveBtn.style.transform  = 'translateY(24px)';
			saveBtn.style.opacity    = '0';
			void saveBtn.offsetWidth;
			saveBtn.style.transition = '';
			requestAnimationFrame( function() {
				saveBtn.style.transform = '';
				saveBtn.style.opacity   = '';
			} );
		} else {
			// Transition back out first (position:fixed itself can't be
			// transitioned), only dropping it from the corner once the
			// fade/slide-down has actually finished — otherwise it just
			// snaps back into the footer instantly, mid-animation.
			saveBtn.style.transition = '';
			saveBtn.style.transform  = '';
			saveBtn.style.opacity    = '';
			saveBtn.classList.add( 'ifp-save-btn--floating-hidden' );
			saveBtn.addEventListener( 'transitionend', function onEnd( e ) {
				if ( 'opacity' !== e.propertyName ) return;
				saveBtn.classList.remove( 'ifp-save-btn--floating', 'ifp-save-btn--floating-hidden' );
				saveBtn.removeEventListener( 'transitionend', onEnd );
			} );
		}
	}
	if ( footer && 'IntersectionObserver' in window ) {
		new IntersectionObserver( function( entries ) {
			footerVisible = entries[ 0 ].isIntersecting;
			updateFloating();
		} ).observe( footer );
	}

	function checkDirty() {
		var changed = serializeForm() !== initialState;
		dirty = changed;
		if ( saveBtn ) saveBtn.classList.toggle( 'ifp-save-btn--pulse', changed );
		updateFloating();
	}

	// Mark dirty on any input/change inside the form.
	form.addEventListener( 'input', checkDirty );
	form.addEventListener( 'change', checkDirty );

	// Clear dirty on form submit.
	form.addEventListener( 'submit', function() {
		dirty = false;
		if ( saveBtn ) saveBtn.classList.remove( 'ifp-save-btn--pulse', 'ifp-save-btn--floating', 'ifp-save-btn--floating-hidden' );
	} );

	window.addEventListener( 'beforeunload', function( e ) {
		if ( ! dirty ) return;
		e.preventDefault();
		e.returnValue = '';
		return '';
	} );
} )();

// ── Settings page: logs accordion toggle ─────────────────────────────────
( function() {
	var btn  = document.getElementById( 'ifp-logs-toggle' );
	var body = document.getElementById( 'ifp-logs-body' );
	var acc  = document.getElementById( 'ifp-logs-accordion' );
	if ( ! btn || ! body ) return;
	btn.addEventListener( 'click', function() {
		var open = acc.classList.toggle( 'ifp-logs-accordion--open' );
		btn.setAttribute( 'aria-expanded', open ? 'true' : 'false' );
	} );
} )();

