<?php
namespace IdeoFieldPorter;

defined( 'ABSPATH' ) || exit;

/**
 * Admin_Page is instantiated once per custom post type.
 * Each instance manages its own menu, actions and sessions independently.
 */
class Admin_Page {

	/** @var string  Slug of the target custom post type */
	private $post_type;

	/** @var \WP_Post_Type  Post type object */
	private $post_type_obj;

	/** @var string  WordPress page slug (unique per context) */
	private $page_slug;

	/** @var string  Prefix used for actions, transients and nonces */
	private $prefix;

	/** @var array  Already-instantiated post types (prevents duplicates) */
	private static $registered = array();

	/**
	 * @param string $post_type  Slug of the target CPT.
	 */
	public function __construct( $post_type ) {
		// Prevent duplicate registration for the same post type.
		if ( isset( self::$registered[ $post_type ] ) ) return;
		self::$registered[ $post_type ] = true;

		$this->post_type     = $post_type;
		$this->post_type_obj = get_post_type_object( $post_type );
		$this->page_slug     = 'spreadsheet-importer-' . $post_type;
		$this->prefix        = 'ideo_fp_' . $post_type;

		// If admin_menu already fired (e.g. late instantiation), call directly.
		if ( did_action( 'admin_menu' ) ) {
			$this->register_menu();
		} else {
			add_action( 'admin_menu', array( $this, 'register_menu' ) );
		}

		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) );

		add_action( 'admin_post_' . $this->prefix . '_upload',         array( $this, 'handle_upload' ) );
		add_action( 'admin_post_' . $this->prefix . '_run',            array( $this, 'handle_import' ) );
		add_action( 'admin_post_' . $this->prefix . '_undo',           array( $this, 'handle_undo' ) );
		add_action( 'wp_ajax_' . $this->prefix . '_delete_profile', array( $this, 'handle_delete_profile' ) );
		add_action( 'admin_post_' . $this->prefix . '_export', array( $this, 'handle_export' ) );
		add_action( 'wp_ajax_' . $this->prefix . '_preview',  array( $this, 'handle_preview' ) );
		add_action( 'wp_ajax_' . $this->prefix . '_save_import_profile', array( $this, 'handle_save_import_profile' ) );
		add_action( 'wp_ajax_' . $this->prefix . '_check_duplicates',    array( $this, 'handle_check_duplicate_titles' ) );
	}

	// ── Menu ──────────────────────────────────────────────────────────────────

	public function register_menu() {
		// For the built-in 'post' type, the parent menu is 'edit.php' (no post_type param).
		// For all other post types it is 'edit.php?post_type={type}'.
		$parent_slug = ( 'post' === $this->post_type )
			? 'edit.php'
			: 'edit.php?post_type=' . $this->post_type;

		add_submenu_page(
			$parent_slug,
			__( 'ideo Field Porter', 'ideo-field-porter' ),
			__( 'ideo Field Porter', 'ideo-field-porter' ),
			\IdeoFieldPorter\Settings_Page::CAP_ACCESS,
			$this->page_slug,
			array( $this, 'render_page' )
		);
	}

	/**
	 * Get the correct admin URL for this plugin page.
	 * For post type 'post', omit post_type param to match the registered parent 'edit.php'.
	 */
	private function get_page_url( $extra = '' ) {
		// For the built-in 'post' type, WordPress uses 'edit.php' (no post_type param).
		// For all other post types, it uses 'edit.php?post_type=X'.
		if ( 'post' === $this->post_type ) {
			$base = admin_url( 'edit.php?page=' . $this->page_slug );
		} else {
			$base = admin_url( 'edit.php?post_type=' . $this->post_type . '&page=' . $this->page_slug );
		}
		return $extra ? $base . $extra : $base;
	}

	// ── Assets ────────────────────────────────────────────────────────────────

	public function enqueue_assets( $hook ) {
		if ( false === strpos( $hook, $this->page_slug ) ) return;
		wp_enqueue_style( 'ideo-field-porter', IDEO_FP_URL . 'assets/admin.css', array(), IDEO_FP_VERSION );
		wp_enqueue_script( 'ideo-field-porter', IDEO_FP_URL . 'assets/admin.js', array( 'jquery' ), IDEO_FP_VERSION, true );

		// Brand color for the live export table (header background, drag
		// handle, etc.) — free has no per-site configurable header color, so
		// this is the plugin's fixed default rather than a user setting.
		$_color = \IdeoFieldPorter\Settings_Page::DEFAULT_COLOR;
		$_light = \IdeoFieldPorter\Settings_Page::lighten_color( $_color, 0.90 );
		wp_add_inline_style( 'ideo-field-porter', ':root { --ifp-preview-color: ' . esc_attr( $_color ) . '; --ifp-preview-light-color: ' . esc_attr( $_light ) . '; }' );

		wp_localize_script( 'ideo-field-porter', 'ideoFpI18n', array(
			'confirmDelete' => __( 'Delete this profile?', 'ideo-field-porter' ),
			'confirmUndo'   => __( 'This will permanently delete all records created during the last import. Continue?', 'ideo-field-porter' ),
			'confirmResetMapping' => __( 'Reset the mapping to the default (auto-detected) configuration? Unsaved changes will be lost.', 'ideo-field-porter' ),
			'ajaxUrl'       => admin_url( 'admin-ajax.php' ),
			'previewNonce'  => wp_create_nonce( $this->prefix . '_preview' ),
			'previewAction' => $this->prefix . '_preview',
			'postType'      => $this->post_type,
			'previewLabel'  => __( 'Preview', 'ideo-field-porter' ),
			'previewTitle'  => __( 'Export preview', 'ideo-field-porter' ),
			// translators: %d is the number of posts shown in the preview.
			'previewShowing'   => __( 'Showing the first %d posts only', 'ideo-field-porter' ),
			'previewClose'     => __( 'Close', 'ideo-field-porter' ),
			'previewExport'    => __( 'Export', 'ideo-field-porter' ),
			'previewLoading'   => __( 'Generating preview…', 'ideo-field-porter' ),
			// Both "save profile" and "delete profile" are AJAX (no-reload), so
			// removing a chip doesn't jump the page back to the top.
			'deleteProfile'            => __( 'Delete this profile', 'ideo-field-porter' ),
			'importProfileNonce'       => wp_create_nonce( $this->prefix . '_save_profile' ),
			'saveImportProfileAction'  => $this->prefix . '_save_import_profile',
			'deleteProfileNonce'       => wp_create_nonce( $this->prefix . '_delete_profile' ),
			'deleteProfileAction'      => $this->prefix . '_delete_profile',
			'noSavedProfiles'          => __( 'No profile saved yet.', 'ideo-field-porter' ),
			'importProfileType'        => $this->post_type,
			'checkDuplicatesNonce'     => wp_create_nonce( $this->prefix . '_check_duplicates' ),
			'checkDuplicatesAction'    => $this->prefix . '_check_duplicates',
			'checkingDuplicates'       => __( 'Checking…', 'ideo-field-porter' ),
			// translators: %d is the number of duplicate post titles found.
			'dupeModalTitleSingle'     => _n( '%d post with the same title already exists', '%d posts with the same title already exist', 1, 'ideo-field-porter' ),
			// translators: %d is the number of duplicate post titles found.
			'dupeModalTitlePlural'     => _n( '%d post with the same title already exists', '%d posts with the same title already exist', 2, 'ideo-field-porter' ),
			'dupeModalIntro'           => __( 'The following titles from your file already exist as published posts. What should happen to them?', 'ideo-field-porter' ),
			'dupeReplaceAll'           => __( 'Replace all', 'ideo-field-porter' ),
			'dupeReplaceSelected'      => __( 'Replace selected', 'ideo-field-porter' ),
			'dupeDeselectAll'          => __( 'Deselect all', 'ideo-field-porter' ),
			'dupeIgnore'               => __( 'Ignore', 'ideo-field-porter' ),
			'dupeClose'                => __( 'Close', 'ideo-field-porter' ),
			'dragToReorder'            => __( 'Drag to reorder', 'ideo-field-porter' ),
			'moveLeft'                 => __( 'Move left', 'ideo-field-porter' ),
			'moveRight'                => __( 'Move right', 'ideo-field-porter' ),
			'sepTitle'                 => __( 'Value separator', 'ideo-field-porter' ),
			'liveTableEmpty'           => __( 'No columns selected — bring one back from the list below.', 'ideo-field-porter' ),
			'excludedColumns'          => __( 'Non-exported columns (click to bring back)', 'ideo-field-porter' ),
			'requiredColumn'           => __( 'Required column — cannot be removed', 'ideo-field-porter' ),
			'removeColumn'             => __( 'Remove column', 'ideo-field-porter' ),
			'idColumnTooltip'          => __( 'Unique identifier automatically generated by WordPress', 'ideo-field-porter' ),
			/* translators: %s is replaced client-side with the profile name. */
			'profileLoaded'            => __( 'Profile loaded: %s', 'ideo-field-porter' ),
		) );
	}

	// ── Router ────────────────────────────────────────────────────────────────

	public function render_page() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display routing; no data is written based on these values.
		$tab  = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'export';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display routing; no data is written based on these values.
		$step = isset( $_GET['step'] ) ? (int) $_GET['step'] : 1;
		$feedback = $this->get_feedback();

		$cpt_label = $this->post_type_obj ? $this->post_type_obj->labels->name : ucfirst( $this->post_type );
		/* translators: %s: post type label (e.g. "Products") */
		$title     = sprintf( __( 'ideo Field Porter → %s', 'ideo-field-porter' ), $cpt_label );

		$page_url  = $this->get_page_url();
		?>

		
		<div class="wrap ifp-wrap ifp-mode-<?php echo esc_attr( \IdeoFieldPorter\Settings_Page::get_mode() ); ?>">
		<div class="ifp-shell">

			<div class="ifp-shell__header">
				<div class="ifp-shell__brand">
					<span class="ifp-shell__brand-icon"><?php echo file_get_contents( plugin_dir_path( __FILE__ ) . 'images/field-porter-icon.svg' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Static SVG bundled with the plugin, not user input; inlined so it can inherit currentColor via CSS. ?></span>
					<div>
						<div class="ifp-shell__plugin-name"><?php esc_html_e( 'ideo Field Porter', 'ideo-field-porter' ); ?></div>
						<div class="ifp-shell__page-title"><?php echo esc_html( $cpt_label ); ?></div>
					</div>
				</div>
				<div class="ifp-shell__header-actions">
					<a href="<?php echo esc_url( admin_url( 'options-general.php?page=ideo-field-porter-settings' ) ); ?>" class="button ifp-btn-danger"><?php esc_html_e( 'Settings', 'ideo-field-porter' ); ?></a>
					<a href="<?php echo esc_url( IDEO_FP_PRO_URL ); ?>" target="_blank" rel="noopener noreferrer" class="ifp-buy"><?php esc_html_e( 'PRO version', 'ideo-field-porter' ); ?></a>
				</div>
			</div>

			<nav class="ifp-shell__tabs">
				<a href="<?php echo esc_url( $page_url . '&tab=export' ); ?>"
				   class="ifp-shell__tab <?php echo 'export' === $tab ? 'ifp-shell__tab--active' : ''; ?>">
					▼ <?php esc_html_e( 'Export', 'ideo-field-porter' ); ?>
				</a>
				<a href="<?php echo esc_url( $page_url . '&tab=import' ); ?>"
				   class="ifp-shell__tab <?php echo 'import' === $tab ? 'ifp-shell__tab--active' : ''; ?>">
					▲ <?php esc_html_e( 'Import', 'ideo-field-porter' ); ?>
				</a>
			</nav>

			<div class="ifp-shell__body">

			<?php if ( $feedback ) : ?>
				<div class="ifp-notice ifp-notice--<?php echo esc_attr( $feedback['type'] ); ?>">
					<?php foreach ( $feedback['messages'] as $msg ) : ?>
						<p><?php echo wp_kses_post( $msg ); ?></p>
					<?php endforeach; ?>
					<?php if ( ! empty( $feedback['undo_ids'] ) ) : ?>
						<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" id="ifp-undo-form" class="ifp-undo-form">
							<?php wp_nonce_field( $this->prefix . '_undo', 'ideo_fp_nonce' ); ?>
							<input type="hidden" name="action" value="<?php echo esc_attr( $this->prefix . '_undo' ); ?>">
							<input type="hidden" name="post_ids" value="<?php echo esc_attr( implode( ',', array_map( 'intval', $feedback['undo_ids'] ) ) ); ?>">
							<button type="submit" class="button ifp-btn-undo">
								↩ <?php esc_html_e( 'Undo import', 'ideo-field-porter' ); ?>
							</button>
						</form>
					<?php endif; ?>
				</div>
			<?php endif; ?>

			<?php if ( 'export' === $tab ) : ?>
				<?php $this->render_export_standard_tab(); ?>
			<?php else : ?>
				<?php
				$this->render_steps_indicator( $step );
				if ( 2 === $step ) :
					$this->render_mapping_step();
				else :
					$this->render_upload_step();
				endif;
				?>
			<?php endif; ?>

			</div>
		</div>
		</div>
		<?php
	}

	// ── Steps indicator ───────────────────────────────────────────────────────

	private function render_steps_indicator( $current ) {
		$steps = array(
			1 => __( '1. Choose file', 'ideo-field-porter' ),
			2 => __( '2. Map columns', 'ideo-field-porter' ),
		);
		echo '<div class="ifp-steps">';
		foreach ( $steps as $n => $label ) {
			$class = 'ifp-step';
			if ( $n === $current ) $class .= ' ifp-step--active';
			if ( $n < $current )   $class .= ' ifp-step--done';
			echo '<div class="' . esc_attr( $class ) . '"><span class="ifp-step__num">' . esc_html( $n ) . '</span><span class="ifp-step__label">' . esc_html( $label ) . '</span></div>';
			if ( $n < count( $steps ) ) echo '<div class="ifp-step__arrow">›</div>';
		}
		echo '</div>';
	}

	// ── Step 1: upload ────────────────────────────────────────────────────────

	private function render_upload_step() {
		?>
		<div class="ifp-card">
			<h2><?php esc_html_e( 'Import a spreadsheet file', 'ideo-field-porter' ); ?></h2>
			<div class="ifp-notice--info">
				<?php esc_html_e( 'For best results, import a file previously exported by ideo Field Porter. The column structure and field names must match your ACF configuration — importing an arbitrary spreadsheet may produce incomplete or unexpected results.', 'ideo-field-porter' ); ?>
			</div>
			<p class="ifp-description">
				<?php esc_html_e( 'Drop or select a .xlsx, .csv or .xml file. You will then be able to map each column to its corresponding field.', 'ideo-field-porter' ); ?>
			</p>
			<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" enctype="multipart/form-data" class="ifp-form">
				<?php wp_nonce_field( $this->prefix . '_upload', 'ideo_fp_nonce' ); ?>
				<input type="hidden" name="action" value="<?php echo esc_attr( $this->prefix . '_upload' ); ?>">

				<div id="drag-drop-area" class="ifp-dropzone">
					<span class="ifp-dropzone__icon dashicons dashicons-upload"></span>
					<p id="dd-text"><?php esc_html_e( 'Drop your file here or click to select', 'ideo-field-porter' ); ?></p>
					<p id="dd-file"></p>
					<button type="button" id="dd-remove" class="ifp-hidden">
						✕ <?php esc_html_e( 'Remove file', 'ideo-field-porter' ); ?>
					</button>
					<input type="file" name="excel_file" id="excel_file" accept=".xlsx,.csv,.xml,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv,application/xml,text/xml" class="ifp-hidden">
				</div>

				<div class="ifp-actions">
					<?php submit_button( __( 'Continue →', 'ideo-field-porter' ), 'primary ifp-submit', 'submit', false ); ?>
				</div>
			</form>
		</div>
		<?php
	}

	// ── Step 2: mapping ───────────────────────────────────────────────────────

	private function render_mapping_step() {
		$session_key = $this->prefix . '_session_' . get_current_user_id();
		$session     = get_transient( $session_key );

		if ( ! $session || empty( $session['headers'] ) || empty( $session['file'] ) ) {
			$this->render_upload_step();
			return;
		}

		$excel_headers = $session['headers'];
		$acf_fields    = $this->get_acf_fields();
		$taxonomies    = $this->get_post_type_taxonomies();
		$profiles      = Mapping_Profiles::get_all( $this->post_type );
		$page_url      = $this->get_page_url();
		$current_mapping = isset( $session['current_mapping'] ) ? $session['current_mapping'] : array();

		// Build a map of repeater labels → field key, to auto-detect "Label #N" columns.
		// Index both the full label ("Group › Contact") and the short label ("Contact")
		// since the exporter uses only the field's own label for column headers.
		$repeater_label_map = array(); // lowercase label variant => field_key
		foreach ( $acf_fields as $f ) {
			if ( 'repeater' === $f['type'] ) {
				// Full label (may include "Parent › " prefix).
				$repeater_label_map[ strtolower( trim( $f['label'] ) ) ] = $f['key'];
				// Short label: last segment after " › " separator.
				$parts = explode( '›', $f['label'] );
				$short = strtolower( trim( end( $parts ) ) );
				if ( $short !== strtolower( trim( $f['label'] ) ) ) {
					$repeater_label_map[ $short ] = $f['key'];
				}
			}
		}

		// Detect which columns are repeater iterations and group them.
		// $repeater_cols_seen: label => [ col_name, ... ] (first seen = representative row)
		$repeater_cols_seen = array(); // repeater_key => first col name
		$col_is_repeater    = array(); // col_name => repeater_key (or false)
		foreach ( $excel_headers as $col ) {
			$matched_key = false;
			if ( preg_match( '/^(.+?)\s*#\d+$/', $col, $m ) ) {
				$prefix = strtolower( trim( $m[1] ) );
				if ( isset( $repeater_label_map[ $prefix ] ) ) {
					$matched_key = $repeater_label_map[ $prefix ];
				} else {
					// Also try matching only the last segment (after " › ") of the prefix.
					$prefix_parts = explode( '›', $prefix );
					$prefix_short = strtolower( trim( end( $prefix_parts ) ) );
					if ( isset( $repeater_label_map[ $prefix_short ] ) ) {
						$matched_key = $repeater_label_map[ $prefix_short ];
					}
				}
			}
			$col_is_repeater[ $col ] = $matched_key;
			if ( $matched_key && ! isset( $repeater_cols_seen[ $matched_key ] ) ) {
				$repeater_cols_seen[ $matched_key ] = $col; // first column = representative
			}
		}

		// ── Auto-match Excel columns to ACF fields/special fields by label ──────
		// (Same logic as hierarchical mode, adapted for flat column names.)
		$acf_label_map = array();
		foreach ( $acf_fields as $af ) {
			$acf_label_map[ strtolower( trim( $af['label'] ) ) ] = $af['key'];
		}

		$auto_map = array(); // col_name => destination value

		foreach ( $excel_headers as $col ) {
			// Repeater representative columns are handled separately via $col_is_repeater.
			if ( ! empty( $col_is_repeater[ $col ] ) ) continue;

			$col_label = strtolower( trim( $col ) );
			$matched   = false;

			// 1. Exact label match against ACF fields.
			if ( isset( $acf_label_map[ $col_label ] ) ) {
				$auto_map[ $col ] = $acf_label_map[ $col_label ];
				$matched = true;
			}

			// 2. Partial path match: compare last segment of a "Group › Field" label.
			if ( ! $matched ) {
				foreach ( $acf_fields as $af ) {
					$af_parts = explode( '›', strtolower( $af['label'] ) );
					$af_last  = trim( end( $af_parts ) );
					if ( $af_last === $col_label ) {
						$auto_map[ $col ] = $af['key'];
						$matched = true;
						break;
					}
				}
			}

			// 3. WordPress special fields.
			if ( ! $matched && in_array( $col_label, array( 'title', 'titre' ), true ) ) {
				$auto_map[ $col ] = '__title__';
				$matched = true;
			}
			if ( ! $matched && ( strpos( $col_label, 'featured image' ) !== false || strpos( $col_label, 'image mise en avant' ) !== false ) ) {
				$auto_map[ $col ] = '__featured_image__';
				$matched = true;
			}
			if ( ! $matched ) {
				foreach ( $taxonomies as $tax ) {
					if ( strtolower( $tax->labels->singular_name ) === $col_label ) {
						$auto_map[ $col ] = '__tax_' . $tax->name . '__';
						$matched = true;
						break;
					}
				}
			}
		}

		// Column → destination value the "Default" button restores: the same
		// value each row is pre-selected with on a fresh upload (auto-matched,
		// or the repeater's own key for repeater representative columns) —
		// never a saved profile or the user's own edits, unlike $current_val.
		$default_map = array();
		foreach ( $excel_headers as $col ) {
			$rep_key = isset( $col_is_repeater[ $col ] ) ? $col_is_repeater[ $col ] : false;
			$default_map[ $col ] = $rep_key ? $rep_key : ( isset( $auto_map[ $col ] ) ? $auto_map[ $col ] : '' );
		}

		// Title label from post type object
		$singular_label = $this->post_type_obj
			? $this->post_type_obj->labels->singular_name
			: ucfirst( $this->post_type );
		/* translators: %s: singular post type label */
		$title_label = sprintf( __( '%s title', 'ideo-field-porter' ), $singular_label );
		?>
		<div class="ifp-card">
			<h2><?php esc_html_e( 'Map columns to fields', 'ideo-field-porter' ); ?></h2>

			<?php
			// Alert if file looks structurally complex (merged headers) — mapping may be incomplete.
			$_ext = isset( $session['ext'] ) ? $session['ext'] : '';
			if ( 'xlsx' === $_ext && $this->is_hierarchical_xlsx( $session['file'], $_ext ) ) :
			?>
			<div class="ifp-notice--info">
				<?php esc_html_e( 'This file appears to have a complex structure (multi-row merged headers). Only the first header row will be used — some columns may be missing or incorrect. Please double-check your mapping below.', 'ideo-field-porter' ); ?>
			</div>
			<?php endif; ?>

			<?php
			// Multi-sheet notice: shown when the uploaded XLSX has > 1 sheet.
			$sheet_names = ! empty( $session['sheet_names'] ) ? $session['sheet_names'] : array();
			$is_multi    = count( $sheet_names ) > 1 && 'xlsx' === ( isset( $session['ext'] ) ? $session['ext'] : '' );
			if ( $is_multi ) :
			?>
			<div class="ifp-multisheet-notice">
				<strong><?php esc_html_e( 'Multiple sheets detected:', 'ideo-field-porter' ); ?></strong>
				<?php echo esc_html( implode( ', ', $sheet_names ) ); ?>
			</div>
			<?php endif; ?>

			<?php wp_localize_script( 'ideo-field-porter', 'ideoFpProfiles', $profiles ); ?>
			<?php wp_localize_script( 'ideo-field-porter', 'ideoFpDefaultMapping', $default_map ); ?>

			<p class="ifp-description">
				<?php esc_html_e( 'For each column in your file, select the corresponding field. Unmapped columns will be ignored.', 'ideo-field-porter' ); ?>
				<?php esc_html_e( 'For fields that can hold multiple values, specify the character that separates values within a cell.', 'ideo-field-porter' ); ?>
				<?php esc_html_e( 'Columns matching a field name, the title, the featured image or a taxonomy are pre-selected automatically — review and adjust the mapping before importing.', 'ideo-field-porter' ); ?>
			</p>

			<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="ifp-form ifp-form--wide" id="ifp-mapping-form">
				<?php wp_nonce_field( $this->prefix . '_run', 'ideo_fp_nonce' ); ?>
				<input type="hidden" name="action" value="<?php echo esc_attr( $this->prefix . '_run' ); ?>">

				<table class="ifp-mapping-table widefat">
					<thead>
						<tr>
							<th><?php esc_html_e( 'Column', 'ideo-field-porter' ); ?></th>
							<th><?php esc_html_e( 'Preview', 'ideo-field-porter' ); ?></th>
							<th><?php esc_html_e( 'Destination field', 'ideo-field-porter' ); ?></th>
							<th><?php esc_html_e( 'Separator', 'ideo-field-porter' ); ?></th>
						</tr>
					</thead>
					<tbody>
					<?php foreach ( $excel_headers as $col ) :
						$rep_key = isset( $col_is_repeater[ $col ] ) ? $col_is_repeater[ $col ] : false;

						// If this is a repeater #N column that is NOT the first occurrence: skip (hidden).
						if ( $rep_key && isset( $repeater_cols_seen[ $rep_key ] ) && $repeater_cols_seen[ $rep_key ] !== $col ) :
							// Emit a hidden input so the repeater key is submitted for each #N column.
							echo '<input type="hidden" name="mapping[' . esc_attr( $col ) . ']" value="' . esc_attr( $rep_key ) . '">';
							continue;
						endif;

						$preview     = $this->get_column_preview( $session, $col );
						$input_name  = 'mapping[' . esc_attr( $col ) . ']';
						// Saved profile takes priority over auto-match.
						$_cm = is_array( $current_mapping ) && isset( $current_mapping['mapping'] ) ? $current_mapping['mapping'] : $current_mapping;
						if ( $rep_key ) {
							$current_val = $rep_key;
						} elseif ( ! empty( $_cm ) ) {
							$current_val = isset( $_cm[ $col ] ) ? $_cm[ $col ] : '';
						} else {
							$current_val = isset( $auto_map[ $col ] ) ? $auto_map[ $col ] : '';
						}

						// For repeater representative row: count total #N columns.
						$rep_count = 0;
						$rep_label_display = '';
						if ( $rep_key ) {
							foreach ( $excel_headers as $_c ) {
								if ( isset( $col_is_repeater[ $_c ] ) && $col_is_repeater[ $_c ] === $rep_key ) $rep_count++;
							}
							preg_match( '/^(.+?)\s*#\d+$/', $col, $_m );
							$rep_label_display = isset( $_m[1] ) ? trim( $_m[1] ) : $col;
						}
					?>
						<tr class="ifp-mapping-row">
							<td class="ifp-col-name">
								<code><?php echo esc_html( $rep_key ? $rep_label_display : $col ); ?></code>
							</td>
							<td class="ifp-col-preview">
								<span class="ifp-preview" data-raw="<?php echo esc_attr( $preview ); ?>" title="<?php echo esc_attr( $preview ); ?>"><?php echo esc_html( $preview ); ?></span>
							</td>
							<td class="ifp-col-select">
								<?php if ( $rep_key ) : ?>
								<select name="<?php echo esc_attr( $input_name ); ?>" class="ifp-select" data-column="<?php echo esc_attr( $col ); ?>">
									<option value=""><?php esc_html_e( '— Ignore —', 'ideo-field-porter' ); ?></option>
									<?php if ( ! empty( $taxonomies ) ) : ?>
										<optgroup label="<?php esc_attr_e( 'Taxonomies', 'ideo-field-porter' ); ?>">
											<?php foreach ( $taxonomies as $tax ) : ?>
												<option value="__tax_<?php echo esc_attr( $tax->name ); ?>__"
													data-type="taxonomy"
													<?php selected( $current_val, '__tax_' . $tax->name . '__' ); ?>>
													<?php echo esc_html( $tax->labels->singular_name ); ?>
												</option>
											<?php endforeach; ?>
										</optgroup>
									<?php endif; ?>
									<?php if ( ! empty( $acf_fields ) ) : ?>
										<optgroup label="<?php esc_attr_e( 'ACF Fields', 'ideo-field-porter' ); ?>">
											<?php foreach ( $acf_fields as $field ) : ?>
												<option value="<?php echo esc_attr( $field['key'] ); ?>"
													data-type="<?php echo esc_attr( isset( $field['type'] ) ? $field['type'] : '' ); ?>"
													<?php selected( $current_val, $field['key'] ); ?>>
													<?php echo esc_html( $field['label'] ); ?>
												</option>
											<?php endforeach; ?>
										</optgroup>
									<?php endif; ?>
									<option value="__featured_image__" <?php selected( $current_val, '__featured_image__' ); ?>><?php esc_html_e( 'Featured Image (URL)', 'ideo-field-porter' ); ?></option>
								</select>
								<?php else : ?>
								<select name="<?php echo esc_attr( $input_name ); ?>" class="ifp-select" data-column="<?php echo esc_attr( $col ); ?>">
									<option value=""><?php esc_html_e( '— Ignore —', 'ideo-field-porter' ); ?></option>
									<option value="__title__" <?php selected( $current_val, '__title__' ); ?>><?php echo esc_html( $title_label ); ?></option>

									<?php if ( ! empty( $taxonomies ) ) : ?>
										<optgroup label="<?php esc_attr_e( 'Taxonomies', 'ideo-field-porter' ); ?>">
											<?php foreach ( $taxonomies as $tax ) : ?>
												<option value="__tax_<?php echo esc_attr( $tax->name ); ?>__"
													data-type="taxonomy"
													<?php selected( $current_val, '__tax_' . $tax->name . '__' ); ?>>
													<?php echo esc_html( $tax->labels->singular_name ); ?>
												</option>
											<?php endforeach; ?>
										</optgroup>
									<?php endif; ?>

									<?php if ( ! empty( $acf_fields ) ) : ?>
										<optgroup label="<?php esc_attr_e( 'ACF Fields', 'ideo-field-porter' ); ?>">
											<?php foreach ( $acf_fields as $field ) : ?>
												<option value="<?php echo esc_attr( $field['key'] ); ?>"
													data-type="<?php echo esc_attr( isset( $field['type'] ) ? $field['type'] : '' ); ?>"
													<?php selected( $current_val, $field['key'] ); ?>>
													<?php echo esc_html( $field['label'] ); ?>
												</option>
											<?php endforeach; ?>
										</optgroup>
									<?php endif; ?>
									<option value="__featured_image__" <?php selected( $current_val, '__featured_image__' ); ?>><?php esc_html_e( 'Featured Image (URL)', 'ideo-field-porter' ); ?></option>
								</select>
								<?php endif; ?>
							</td>
							<td class="ifp-col-sep">
								<select class="ifp-sep-select ifp-hidden" data-col="<?php echo esc_attr( $col ); ?>">
									<option value=",">,</option>
									<option value="|" selected>|</option>
									<option value="-">-</option>
									<option value="/">/</option>
								</select>
								<input type="hidden" name="separator[<?php echo esc_attr( $col ); ?>]" class="ifp-sep-hidden" value="|">
							</td>
						</tr>
					<?php endforeach; ?>
					</tbody>
				</table>
			</form>
			</div>

			<div class="ifp-card-grid">
				<div class="ifp-card ifp-card--sub" id="ifp-import-profiles-card">
					<div class="ifp-card__label"><?php esc_html_e( 'Load profile', 'ideo-field-porter' ); ?></div>
					<div class="ifp-profiles-list">
						<div class="ifp-profile-chip ifp-profile-chip--danger">
							<button type="button" id="ifp-import-default-btn" class="ifp-profile-load button button-small" title="<?php esc_attr_e( 'Reset the mapping to the default (auto-detected) configuration', 'ideo-field-porter' ); ?>"><?php esc_html_e( 'Default', 'ideo-field-porter' ); ?></button>
						</div>
						<?php if ( empty( $profiles ) ) : ?>
						<p class="ifp-card__empty"><?php esc_html_e( 'No profile saved yet.', 'ideo-field-porter' ); ?></p>
						<?php else : ?>
						<?php foreach ( $profiles as $name => $saved_mapping ) : ?>
						<div class="ifp-profile-chip">
							<button type="button" class="ifp-profile-load button button-small" data-profile="<?php echo esc_attr( $name ); ?>">
								<?php echo esc_html( $name ); ?>
							</button>
							<form class="ifp-profile-delete-form">
								<?php wp_nonce_field( $this->prefix . '_delete_profile', 'ideo_fp_nonce' ); ?>
								<input type="hidden" name="action" value="<?php echo esc_attr( $this->prefix . '_delete_profile' ); ?>">
								<input type="hidden" name="profile_name" value="<?php echo esc_attr( $name ); ?>">
								<button type="submit" class="ifp-profile-delete" title="<?php esc_attr_e( 'Delete this profile', 'ideo-field-porter' ); ?>">×</button>
							</form>
						</div>
						<?php endforeach; ?>
						<?php endif; ?>
					</div>
				</div>
				<div class="ifp-card ifp-card--sub">
					<div class="ifp-card__label"><?php esc_html_e( 'Save profile', 'ideo-field-porter' ); ?></div>
					<div class="ifp-save-profile">
						<input type="text" id="ifp-profile-name" placeholder="<?php esc_attr_e( 'Profile name…', 'ideo-field-porter' ); ?>" class="ifp-profile-name-input regular-text">
						<button type="button" id="ifp-btn-save-profile" class="button">
							💾 <?php esc_html_e( 'Save profile', 'ideo-field-porter' ); ?>
						</button>
					</div>
				</div>
			</div>

			<div class="ifp-shell__footer">
				<div>
					<a href="<?php echo esc_url( $page_url . '&tab=import' ); ?>" class="button ifp-btn-back">
						← <?php esc_html_e( 'Start over', 'ideo-field-porter' ); ?>
					</a>
				</div>
				<div class="ifp-shell__footer-actions">
					<button type="button" class="button ifp-btn-test-import ifp-locked" disabled title="<?php esc_attr_e( 'PRO only: run the import on a single post without overwriting anything, to verify your mapping before a full import.', 'ideo-field-porter' ); ?>">
						🔒 <?php esc_html_e( 'Test import (1 post)', 'ideo-field-porter' ); ?>
					</button>
					<?php submit_button( __( 'Launch import', 'ideo-field-porter' ), 'primary ifp-submit', 'submit', false, array( 'form' => 'ifp-mapping-form' ) ); ?>
				</div>
			</div>
		<?php
	}

	// ── Handle: upload ────────────────────────────────────────────────────────

	public function handle_upload() {
		if ( ! current_user_can( \IdeoFieldPorter\Settings_Page::CAP_ACCESS ) ) wp_die( esc_html__( 'Access denied.', 'ideo-field-porter' ) );
		check_admin_referer( $this->prefix . '_upload', 'ideo_fp_nonce' );

		$redirect_base = $this->get_page_url( '&tab=import' );

		if ( ! isset( $_FILES['excel_file'] ) ||
		     empty( $_FILES['excel_file']['tmp_name'] ) ||
		     ! isset( $_FILES['excel_file']['error'] ) ||
		     UPLOAD_ERR_OK !== (int) $_FILES['excel_file']['error'] ) {
			$this->redirect_with_error( $redirect_base, __( 'No valid file received.', 'ideo-field-porter' ) );
			return;
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- tmp_name is a server-generated path, not user input; sanitize_text_field() would corrupt binary paths.
		$file      = $_FILES['excel_file'];
		$file_name = isset( $file['name'] ) ? sanitize_file_name( wp_unslash( $file['name'] ) ) : '';
		$ext       = strtolower( pathinfo( $file_name, PATHINFO_EXTENSION ) );

		if ( ! in_array( $ext, array( 'xlsx', 'csv', 'xml' ), true ) ) {
			$this->redirect_with_error( $redirect_base, __( 'The file must be a valid .xlsx, .csv or .xml file.', 'ideo-field-porter' ) );
			return;
		}

		$importer = new Spreadsheet_Importer();

		$upload_dir = wp_upload_dir();
		$tmp_dir    = trailingslashit( $upload_dir['basedir'] ) . 'ideo-fp-tmp/';
		wp_mkdir_p( $tmp_dir );
		if ( ! file_exists( $tmp_dir . '.htaccess' ) ) file_put_contents( $tmp_dir . '.htaccess', 'deny from all' );

		// Move the uploaded file via wp_handle_upload() rather than a bare
		// move_uploaded_file() call, so WordPress's own upload checks (genuine
		// HTTP-upload verification via is_uploaded_file(), MIME/extension
		// validation, PHP upload-error handling) run before anything touches
		// the file. The upload_dir filter below redirects the destination to
		// our private temp folder (protected by the .htaccess above) instead
		// of the public media library — this file is a transient working
		// copy for the import wizard, never meant to become a media attachment.
		$redirect_upload_dir = function( $dirs ) use ( $tmp_dir ) {
			$dirs['path']   = untrailingslashit( $tmp_dir );
			$dirs['url']    = '';
			$dirs['subdir'] = '';
			return $dirs;
		};
		add_filter( 'upload_dir', $redirect_upload_dir );

		// WordPress core's site-wide mime whitelist (get_allowed_mime_types())
		// has no 'xml' entry by default, and wp_check_filetype_and_ext() checks
		// against it as a final gate regardless of the 'mimes' override passed
		// to wp_handle_upload() below — without this filter, every .xml file
		// this plugin exports gets rejected by its own import wizard.
		$allow_xml_mime = function( $mimes ) {
			$mimes['xml'] = 'text/xml';
			return $mimes;
		};
		add_filter( 'upload_mimes', $allow_xml_mime );

		require_once ABSPATH . 'wp-admin/includes/file.php';
		$uploaded = wp_handle_upload( $file, array(
			'test_form' => false,
			'mimes'     => array(
				'csv'  => 'text/csv',
				'xml'  => 'text/xml',
				'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
			),
		) );

		remove_filter( 'upload_mimes', $allow_xml_mime );
		remove_filter( 'upload_dir', $redirect_upload_dir );

		if ( ! empty( $uploaded['error'] ) ) {
			$this->redirect_with_error( $redirect_base, $uploaded['error'] );
			return;
		}

		$tmp_file = $uploaded['file'];

		try {
			if ( 'csv' === $ext ) {
				$rows        = $importer->read_csv_public( $tmp_file );
				$sheet_names = array();
			} elseif ( 'xml' === $ext ) {
				$rows        = $importer->read_xml_public( $tmp_file );
				$sheet_names = array();
			} else {
				// Read all sheets to detect multi-sheet XLSX.
				$all_sheets  = $importer->read_xlsx_all_sheets( $tmp_file );
				$sheet_names = array_keys( $all_sheets );
				// Use first sheet for the header/preview.
				$first_sheet = reset( $all_sheets );
				$rows        = $first_sheet;
			}
		} catch ( \Exception $e ) {
			$this->redirect_with_error( $redirect_base, $e->getMessage() );
			return;
		}

		if ( empty( $rows ) ) {
			$this->redirect_with_error( $redirect_base, __( 'The file is empty.', 'ideo-field-porter' ) );
			return;
		}

		// Use the mode defined in Settings — same as export.
		$file_mode = \IdeoFieldPorter\Settings_Page::get_mode();

		// Strip the ID column — post IDs cannot be imported (WordPress assigns new IDs).
		$id_col_idx = array_search( __( 'ID', 'ideo-field-porter' ), $rows[0], true );
		$headers_filtered = $rows[0];
		$preview_filtered = isset( $rows[1] ) ? $rows[1] : array();
		if ( false !== $id_col_idx ) {
			array_splice( $headers_filtered, $id_col_idx, 1 );
			if ( ! empty( $preview_filtered ) ) array_splice( $preview_filtered, $id_col_idx, 1 );
		}

		set_transient( $this->prefix . '_session_' . get_current_user_id(), array(
			'file'        => $tmp_file,
			'filename'    => $file_name,
			'ext'         => $ext,
			'file_mode'   => $file_mode,
			'headers'     => $headers_filtered,
			'preview'     => array( $preview_filtered ),
			'sheet_names' => $sheet_names,
		), 30 * MINUTE_IN_SECONDS );

		wp_safe_redirect( $redirect_base . '&step=2' );
		exit;
	}

	// ── Handle: import ────────────────────────────────────────────────────────

	/**
	 * Titles the user explicitly chose to overwrite in the duplicate-titles
	 * modal (checked items, or every duplicate found when none were checked
	 * and "Replace all" was clicked) — sent as a JSON array under
	 * duplicate_titles_replace. Empty/absent means "ignore every duplicate",
	 * same as the old default.
	 *
	 * @return string[]
	 */
	private function get_duplicate_titles_to_replace() {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- this method's only caller, handle_import(), already ran check_admin_referer() before reaching this point.
		if ( ! isset( $_POST['duplicate_titles_replace'] ) ) return array();
		// phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- same nonce already verified by the caller; decoded JSON is sanitized value-by-value below, the raw string itself is never used.
		$decoded = json_decode( wp_unslash( $_POST['duplicate_titles_replace'] ), true );
		if ( ! is_array( $decoded ) ) return array();
		return array_map( 'sanitize_text_field', $decoded );
	}

	public function handle_import() {
		if ( ! current_user_can( \IdeoFieldPorter\Settings_Page::CAP_ACCESS ) ) wp_die( esc_html__( 'Access denied.', 'ideo-field-porter' ) );
		check_admin_referer( $this->prefix . '_run', 'ideo_fp_nonce' );

		$redirect_base = $this->get_page_url( '&tab=import' );
		$session_key   = $this->prefix . '_session_' . get_current_user_id();
		$session       = get_transient( $session_key );

		if ( ! $session || empty( $session['file'] ) || ! file_exists( $session['file'] ) ) {
			$this->redirect_with_error( $redirect_base, __( 'Session expired. Please start over.', 'ideo-field-porter' ) );
			return;
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Array values are sanitize_key'd in the loop below; keys validated via in_array.
		$mapping    = isset( $_POST['mapping'] ) && is_array( $_POST['mapping'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['mapping'] ) ) : array();
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Rebuilt into a brand-new array below with sanitized keys/values only; original raw keys are discarded, not merely shadowed.
		$raw_separators = isset( $_POST['separator'] ) && is_array( $_POST['separator'] ) ? wp_unslash( $_POST['separator'] ) : array();
		// Sanitize separators — only allow: , - . / — build a fresh array so no
		// unsanitized original key survives alongside its sanitized counterpart.
		$allowed_seps = array( ',', '|', '-', '/', '.' );
		$separators   = array();
		foreach ( $raw_separators as $col => $sep ) {
			$col = sanitize_text_field( $col );
			if ( '' === $col ) continue;
			$separators[ $col ] = in_array( $sep, $allowed_seps, true ) ? $sep : '|';
		}

		if ( ! in_array( '__title__', $mapping, true ) ) {
			$this->redirect_with_error( $redirect_base . '&step=2', __( 'You must map at least one column to the title field.', 'ideo-field-porter' ) );
			return;
		}

		$replace_titles = $this->get_duplicate_titles_to_replace();

		$importer = new Spreadsheet_Importer();

		$result = $importer->import_with_mapping(
			$session['file'],
			$mapping,
			isset( $session['ext'] ) ? $session['ext'] : 'xlsx',
			$this->post_type,
			$separators,
			$replace_titles
		);

		wp_delete_file( $session['file'] );
		delete_transient( $session_key );

		// Labels from CPT object
		$singular = $this->post_type_obj ? strtolower( $this->post_type_obj->labels->singular_name ) : $this->post_type;
		$plural   = $this->post_type_obj ? strtolower( $this->post_type_obj->labels->name ) : $this->post_type . 's';

		$messages = array();
		if ( $result['created'] > 0 ) {
			$label_created = 1 === $result['created'] ? esc_html( $singular ) : esc_html( $plural );
			$messages[]    = wp_kses_post( sprintf(
				/* translators: 1: number of posts created, 2: post type label (singular or plural) */
				_n(
					'<strong>%1$d %2$s</strong> created successfully.',
					'<strong>%1$d %2$s</strong> created successfully.',
					$result['created'],
					'ideo-field-porter'
				),
				$result['created'],
				$label_created
			) );
		}
		$dup_count = ! empty( $result['duplicates'] ) ? count( $result['duplicates'] ) : 0;
		if ( $dup_count > 0 ) {
			$dup_titles = implode( ', ', array_map( 'esc_html', $result['duplicates'] ) );
			$messages[] = sprintf(
				/* translators: 1: number of duplicates, 2: comma-separated list of duplicate post titles */
				_n( '<strong>%1$d duplicate</strong> ignored (already exists): %2$s.', '<strong>%1$d duplicates</strong> ignored (already exist): %2$s.', $dup_count, 'ideo-field-porter' ),
				$dup_count, $dup_titles
			);
		}
		if ( $result['skipped'] > 0 ) {
			$messages[] = sprintf(
				/* translators: %d: number of skipped rows */
				_n( '%d row ignored.', '%d rows ignored.', $result['skipped'], 'ideo-field-porter' ),
				$result['skipped']
			);
		}
		foreach ( $result['errors'] as $err ) {
			$messages[] = '<span class="ifp-error-item">⚠ ' . esc_html( $err ) . '</span>';
		}

		$type = ( empty( $result['errors'] ) && $result['created'] > 0 ) ? 'success' : 'warning';
		if ( 0 === $result['created'] && $dup_count > 0 && empty( $result['errors'] ) ) $type = 'warning';
		if ( 0 === $result['created'] && ! empty( $result['errors'] ) ) $type = 'error';

		$feedback = array( 'type' => $type, 'messages' => $messages );
		if ( ! empty( $result['created_ids'] ) ) {
			$feedback['undo_ids'] = $result['created_ids'];
		}

		\IdeoFieldPorter\Logger::log( array(
			'type'      => 'import',
			'mode'      => 'standard',
			'post_type' => $this->post_type,
			'file'      => isset( $session['filename'] ) ? $session['filename'] : '',
			'status'    => $type,
			'summary'   => array(
				'created' => $result['created'],
				'updated' => 0,
				'skipped' => $result['skipped'],
				'errors'  => count( $result['errors'] ),
			),
		) );

		set_transient( $this->prefix . '_feedback_' . get_current_user_id(), $feedback, 300 );
		wp_safe_redirect( $redirect_base );
		exit;
	}

	// ── Handle: undo ──────────────────────────────────────────────────────────

	public function handle_undo() {
		if ( ! current_user_can( \IdeoFieldPorter\Settings_Page::CAP_ACCESS ) ) wp_die( esc_html__( 'Access denied.', 'ideo-field-porter' ) );
		check_admin_referer( $this->prefix . '_undo', 'ideo_fp_nonce' );

		$redirect_base = $this->get_page_url( '&tab=import' );
		$raw_ids       = isset( $_POST['post_ids'] ) ? sanitize_text_field( wp_unslash( $_POST['post_ids'] ) ) : '';
		$ids           = array_filter( array_map( 'intval', explode( ',', $raw_ids ) ) );

		if ( empty( $ids ) ) {
			$this->redirect_with_error( $redirect_base, __( 'No records to delete.', 'ideo-field-porter' ) );
			return;
		}

		$deleted = 0;
		foreach ( $ids as $id ) {
			if ( wp_delete_post( $id, true ) ) $deleted++;
		}

		set_transient( $this->prefix . '_feedback_' . get_current_user_id(), array(
			'type'     => 'success',
			'messages' => array( sprintf(
				/* translators: %d: number of records deleted */
				_n( '<strong>%d record</strong> deleted successfully.', '<strong>%d records</strong> deleted successfully.', $deleted, 'ideo-field-porter' ),
				$deleted
			) ),
		), 60 );

		wp_safe_redirect( $redirect_base );
		exit;
	}

	// ── Handle: save profile (AJAX, no-reload) ──────────────────────────────────

	public function handle_save_import_profile() {
		check_ajax_referer( $this->prefix . '_save_profile', 'nonce' );
		if ( ! current_user_can( \IdeoFieldPorter\Settings_Page::CAP_ACCESS ) ) wp_send_json_error( 'Access denied.' );

		$name         = isset( $_POST['profile_name'] )    ? sanitize_text_field( wp_unslash( $_POST['profile_name'] ) ) : '';
		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- JSON decoded, then every key/value sanitized below via sanitize_profile_mapping()/sanitize_profile_separators().
		$raw          = isset( $_POST['profile_mapping'] ) ? wp_unslash( $_POST['profile_mapping'] ) : '';
		$profile_type = isset( $_POST['profile_post_type'] ) ? sanitize_key( wp_unslash( $_POST['profile_post_type'] ) ) : $this->post_type;
		$decoded      = json_decode( $raw, true );

		// Support new format {mapping, separators} and old format (plain mapping array).
		// json_decode() does not sanitize its output, so every key/value coming
		// from this user-submitted JSON is cleaned before being persisted.
		if ( isset( $decoded['mapping'] ) ) {
			$mapping    = $this->sanitize_profile_mapping( $decoded['mapping'] );
			$separators = isset( $decoded['separators'] ) ? $this->sanitize_profile_separators( $decoded['separators'] ) : array();
		} else {
			$mapping    = $this->sanitize_profile_mapping( $decoded );
			$separators = array();
		}

		if ( '' === $name || empty( $mapping ) ) {
			wp_send_json_error( __( 'Invalid profile name or empty mapping.', 'ideo-field-porter' ) );
		}

		// Store both mapping and separators in the profile.
		$profile_data = array( 'mapping' => $mapping, 'separators' => $separators );
		Mapping_Profiles::save( $name, $profile_data, $profile_type );

		$session_key = $this->prefix . '_session_' . get_current_user_id();
		$session     = get_transient( $session_key );
		if ( $session ) {
			$session['current_mapping'] = $profile_data;
			set_transient( $session_key, $session, 30 * MINUTE_IN_SECONDS );
		}

		wp_send_json_success( array( 'name' => $name ) );
	}

	// ── Handle: check duplicate titles before import ──────────────────────────

	/**
	 * Given the mapping currently set up in the wizard, reads the title
	 * column's values out of the uploaded file (still on disk, path from the
	 * session) and reports which ones already exist as a published post of
	 * this type — called before "Launch import" actually runs, so the user
	 * can choose Replace/Ignore for this run instead of relying on a global
	 * setting.
	 */
	public function handle_check_duplicate_titles() {
		check_ajax_referer( $this->prefix . '_check_duplicates', 'nonce' );
		if ( ! current_user_can( \IdeoFieldPorter\Settings_Page::CAP_ACCESS ) ) wp_send_json_error( 'Access denied.' );

		$session = get_transient( $this->prefix . '_session_' . get_current_user_id() );
		if ( ! $session || empty( $session['file'] ) || ! file_exists( $session['file'] ) ) {
			wp_send_json_error( __( 'Session expired. Please start over.', 'ideo-field-porter' ) );
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized below via sanitize_profile_mapping().
		$raw_map = isset( $_POST['mapping'] ) && is_array( $_POST['mapping'] ) ? wp_unslash( $_POST['mapping'] ) : array();
		$mapping = $this->sanitize_profile_mapping( $raw_map );

		$titles           = array();
		$importer         = new Spreadsheet_Importer();
		$title_col_label  = array_search( '__title__', $mapping, true );
		if ( false !== $title_col_label ) {
			$ext = isset( $session['ext'] ) ? $session['ext'] : 'xlsx';
			try {
				if ( 'csv' === $ext ) {
					$rows = $importer->read_csv_public( $session['file'] );
				} elseif ( 'xml' === $ext ) {
					$rows = $importer->read_xml_public( $session['file'] );
				} else {
					// Same "merge all sheets" logic as import_with_mapping(): header from
					// the first sheet, data rows appended from every sheet after it.
					$all_sheets = $importer->read_xlsx_all_sheets( $session['file'] );
					$rows       = array();
					$header_set = false;
					foreach ( $all_sheets as $sheet_rows ) {
						if ( empty( $sheet_rows ) ) continue;
						if ( ! $header_set ) { $rows[] = $sheet_rows[0]; $header_set = true; }
						for ( $s = 1; $s < count( $sheet_rows ); $s++ ) $rows[] = $sheet_rows[ $s ];
					}
				}
			} catch ( \Exception $e ) {
				$rows = array();
			}
			if ( count( $rows ) > 1 ) {
				$headers       = $rows[0];
				$title_col_idx = array_search( $title_col_label, $headers, true );
				if ( false !== $title_col_idx ) {
					for ( $i = 1, $total = count( $rows ); $i < $total; $i++ ) {
						$val = isset( $rows[ $i ][ $title_col_idx ] ) ? trim( (string) $rows[ $i ][ $title_col_idx ] ) : '';
						if ( '' !== $val ) $titles[] = $val;
					}
				}
			}
		}

		$duplicates = empty( $titles ) ? array() : $importer->find_existing_titles( $this->post_type, $titles );
		wp_send_json_success( array( 'duplicates' => array_values( $duplicates ) ) );
	}

	// ── Handle: delete profile (AJAX, no-reload — matches the save-profile
	// flow, so deleting a chip doesn't jump the page back to the top). ─────────

	public function handle_delete_profile() {
		check_ajax_referer( $this->prefix . '_delete_profile', 'ideo_fp_nonce' );
		if ( ! current_user_can( \IdeoFieldPorter\Settings_Page::CAP_ACCESS ) ) wp_send_json_error( 'Access denied.' );

		$name         = isset( $_POST['profile_name'] ) ? sanitize_text_field( wp_unslash( $_POST['profile_name'] ) ) : '';
		$profile_type = isset( $_POST['profile_post_type'] ) ? sanitize_key( wp_unslash( $_POST['profile_post_type'] ) ) : $this->post_type;
		Mapping_Profiles::delete( $name, $profile_type );

		wp_send_json_success();
	}

	// ── Export tab ────────────────────────────────────────────────────────────

	// ── Export handler ─────────────────────────────────────────────────────────

	public function handle_export() {
		$this->handle_export_standard();
	}

	public function handle_export_standard() {
		if ( ! current_user_can( \IdeoFieldPorter\Settings_Page::CAP_ACCESS ) ) wp_die( esc_html__( 'Access denied.', 'ideo-field-porter' ) );
		check_admin_referer( $this->prefix . '_export', 'ideo_fp_nonce' );

		$allowed_formats = array( 'xlsx', 'csv', 'xml' );
		$format_raw      = isset( $_POST['export_format'] ) ? sanitize_key( wp_unslash( $_POST['export_format'] ) ) : 'xlsx';
		$format          = in_array( $format_raw, $allowed_formats, true ) ? $format_raw : 'xlsx';

		$raw_columns = isset( $_POST['export_columns'] ) ? sanitize_text_field( wp_unslash( $_POST['export_columns'] ) ) : '';
		$columns     = json_decode( $raw_columns, true );
		if ( ! is_array( $columns ) || empty( $columns ) ) $columns = null;

		$separators = $this->sanitize_export_separators( isset( $_POST['sep'] ) && is_array( $_POST['sep'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['sep'] ) ) : array() );

		$exporter = new Spreadsheet_Exporter_Standard();
		$_pt_obj_std  = get_post_type_object( $this->post_type );
		$_label_std   = $_pt_obj_std ? sanitize_title( $_pt_obj_std->labels->name ) : $this->post_type;
		$_fname_std   = $_label_std . '-export-' . gmdate( 'Y-m-d' ) . '.' . $format;
		\IdeoFieldPorter\Logger::log( array(
			'type'      => 'export',
			'mode'      => 'standard',
			'post_type' => $this->post_type,
			'format'    => $_fname_std,
			'status'    => 'success',
			'summary'   => array(
				'created' => 0,
				'updated' => 0,
				'skipped' => 0,
				'errors'  => 0,
			),
		) );
		$exporter->export( $this->post_type, $format, $columns, $separators );
	}

	/**
	 * Sanitize export separators — only allow: , | - / .
	 */
	private function sanitize_export_separators( $raw ) {
		$allowed = array( ',', '|', '-', '/', '.' );
		$clean   = array();
		if ( ! is_array( $raw ) ) return $clean;
		foreach ( $raw as $key => $sep ) {
			$clean[ sanitize_key( $key ) ] = in_array( $sep, $allowed, true ) ? $sep : '|';
		}
		return $clean;
	}

	/**
	 * Sanitize a decoded import mapping profile before saving it: { spreadsheet
	 * column label => destination field key }. Keys are arbitrary user file
	 * headers (not internal identifiers), so sanitize_text_field() is used
	 * rather than sanitize_key() — json_decode() itself performs no sanitization.
	 */
	private function sanitize_profile_mapping( $mapping ) {
		$clean = array();
		if ( ! is_array( $mapping ) ) return $clean;
		foreach ( $mapping as $col => $dest ) {
			$col = sanitize_text_field( (string) $col );
			if ( '' === $col ) continue;
			$clean[ $col ] = is_scalar( $dest ) ? sanitize_text_field( (string) $dest ) : '';
		}
		return $clean;
	}

	/**
	 * Sanitize a decoded import mapping profile's per-column separators:
	 * { spreadsheet column label => separator character }, restricted to the
	 * same allow-list as export separators.
	 */
	private function sanitize_profile_separators( $separators ) {
		$allowed = array( ',', '|', '-', '/', '.' );
		$clean   = array();
		if ( ! is_array( $separators ) ) return $clean;
		foreach ( $separators as $col => $sep ) {
			$col = sanitize_text_field( (string) $col );
			if ( '' === $col ) continue;
			$clean[ $col ] = in_array( $sep, $allowed, true ) ? $sep : '|';
		}
		return $clean;
	}

	// ── Standard export tab (mode: standard) ────────────────────────────────

	private function render_export_standard_tab() {
		$cpt_label = $this->post_type_obj ? $this->post_type_obj->labels->name : ucfirst( $this->post_type );
		$count     = wp_count_posts( $this->post_type );
		$published = $count ? (int) $count->publish : 0;

		// Build the full list of exportable columns in default order.
		$columns = $this->get_export_columns_standard();

		// Column schema for the live table — same source the old column list
		// used, now read directly by JS instead of being rendered into list
		// markup.
		wp_localize_script( 'ideo-field-porter', 'ideoFpColumnsSchema', $columns );

		$export_taxes = $this->get_post_type_taxonomies();
		?>
		<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="ifp-form<?php echo $published > 0 ? ' ifp-form--wide' : ''; ?>" id="ifp-export-form">
			<?php wp_nonce_field( $this->prefix . '_export', 'ideo_fp_nonce' ); ?>
			<input type="hidden" name="action" value="<?php echo esc_attr( $this->prefix . '_export' ); ?>">
			<input type="hidden" name="export_columns" id="ifp-export-columns-input" value="">

			<div class="ifp-card<?php echo 0 === $published ? ' ifp-card--disabled' : ' ifp-card--wide'; ?>">
				<div class="ifp-card__head">
					<div class="ifp-card__head-title">
						<h2><?php esc_html_e( 'Arrange export columns', 'ideo-field-porter' ); ?></h2>
					</div>
					<?php if ( $published > 0 ) : ?>
					<div class="ifp-card__head-actions">
						<button type="button" id="ifp-live-zoom-out" class="ifp-live-zoom-btn" title="<?php esc_attr_e( 'Zoom out', 'ideo-field-porter' ); ?>">
							<?php echo file_get_contents( plugin_dir_path( __FILE__ ) . 'images/zoom-out.svg' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Static SVG bundled with the plugin, not user input; inlined so it can inherit currentColor via CSS. ?>
						</button>
						<button type="button" id="ifp-live-zoom-in" class="ifp-live-zoom-btn" title="<?php esc_attr_e( 'Zoom in', 'ideo-field-porter' ); ?>">
							<?php echo file_get_contents( plugin_dir_path( __FILE__ ) . 'images/zoom-in.svg' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped,WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Static SVG bundled with the plugin, not user input; inlined so it can inherit currentColor via CSS. ?>
						</button>
					</div>
					<?php endif; ?>
				</div>
				<?php if ( $this->has_nested_acf_fields() ) : ?>
				<div class="ifp-notice--info">
					<?php echo wp_kses_post( sprintf(
						/* translators: %s: link to the PRO product page */
						__( 'Nested Layout fields aren\'t exported in Standard mode — some data will be missing. Get the %s for hierarchical export.', 'ideo-field-porter' ),
						'<a href="' . esc_url( IDEO_FP_PRO_URL ) . '" target="_blank" rel="noopener noreferrer">' . esc_html__( 'PRO version', 'ideo-field-porter' ) . '</a>'
					) ); ?>
				</div>
				<?php endif; ?>
				<p class="ifp-description" id="ifp-export-description">
					<?php if ( 0 === $published ) : ?>
						<?php printf(
							/* translators: %s: post type label */
							esc_html__( 'No published %s found. There is nothing to export.', 'ideo-field-porter' ),
							esc_html( $cpt_label )
						); ?>
					<?php else : ?>
						<?php esc_html_e( 'Drag columns to reorder, click × to exclude, click a header to rename.', 'ideo-field-porter' ); ?>
					<?php endif; ?>
				</p>

				<?php if ( $published > 0 ) : ?>
				<div id="ifp-live-table-toolbar__left"></div>
				<div id="ifp-live-table-wrap" class="ideo-pimp-scrollbar">
					<p id="ifp-live-table-loading"><?php esc_html_e( 'Loading…', 'ideo-field-porter' ); ?></p>
					<div id="ifp-live-table-inner"></div>
				</div>
				<?php endif; ?>
			</div>
		</form>

		<?php if ( 0 === $published ) : ?>
		<div class="ifp-shell__footer">
			<div></div>
			<button type="button" class="button button-primary ifp-submit" disabled>▼ <?php esc_html_e( 'Download file', 'ideo-field-porter' ); ?></button>
		</div>
		<?php else : ?>
		<div class="ifp-card-grid">
			<?php if ( ! empty( $export_taxes ) ) : ?>
			<div class="ifp-card ifp-card--sub ifp-locked" id="ifp-sheet-mode" title="<?php esc_attr_e( 'PRO only: split the export into multiple spreadsheet sheets by taxonomy term.', 'ideo-field-porter' ); ?>">
				<p class="ifp-sheet-mode__desc">
					<?php esc_html_e( 'Create a sheet per taxonomy', 'ideo-field-porter' ); ?>
					<span class="ifp-mode-card__badge ifp-mode-card__badge--pro">🔒 PRO</span>
				</p>
				<div class="ifp-sheet-mode__row">
					<select disabled>
						<option><?php esc_html_e( '— One sheet (all posts) —', 'ideo-field-porter' ); ?></option>
						<?php foreach ( $export_taxes as $tax ) : ?>
						<option><?php echo esc_html( $tax->labels->singular_name . ' (' . $tax->name . ')' ); ?></option>
						<?php endforeach; ?>
					</select>
				</div>
			</div>
			<?php endif; ?>
			<div class="ifp-card ifp-card--sub ifp-locked" title="<?php esc_attr_e( 'PRO only: save and reuse named column arrangements for export.', 'ideo-field-porter' ); ?>">
				<div class="ifp-card__label"><?php esc_html_e( 'Load profile', 'ideo-field-porter' ); ?> <span class="ifp-mode-card__badge ifp-mode-card__badge--pro">🔒 PRO</span></div>
				<p class="ifp-card__empty"><?php esc_html_e( 'No profile saved yet.', 'ideo-field-porter' ); ?></p>
			</div>
			<div class="ifp-card ifp-card--sub ifp-locked" title="<?php esc_attr_e( 'PRO only: save and reuse named column arrangements for export.', 'ideo-field-porter' ); ?>">
				<div class="ifp-card__label"><?php esc_html_e( 'Save profile', 'ideo-field-porter' ); ?> <span class="ifp-mode-card__badge ifp-mode-card__badge--pro">🔒 PRO</span></div>
				<div class="ifp-save-profile">
					<input type="text" placeholder="<?php esc_attr_e( 'Profile name…', 'ideo-field-porter' ); ?>" class="ifp-profile-name-input regular-text" disabled>
					<button type="button" class="button" disabled>💾 <?php esc_html_e( 'Save profile', 'ideo-field-porter' ); ?></button>
				</div>
			</div>
		</div>

		<div class="ifp-shell__footer">
			<div class="ifp-format-choice ifp-format-choice--inline" id="ifp-format-choice">
				<label class="ifp-format-option">
					<input type="radio" name="export_format" value="xlsx" checked id="ifp-fmt-xlsx" form="ifp-export-form">
					<span class="ifp-format-label">
						<span class="ifp-format-icon">📊</span>
						<span class="ifp-format-text"><strong>Excel (.xlsx)</strong></span>
					</span>
				</label>
				<label class="ifp-format-option" id="ifp-fmt-csv-wrap">
					<input type="radio" name="export_format" value="csv" id="ifp-fmt-csv" form="ifp-export-form">
					<span class="ifp-format-label">
						<span class="ifp-format-icon">📄</span>
						<span class="ifp-format-text"><strong>CSV (.csv)</strong></span>
					</span>
				</label>
				<label class="ifp-format-option" id="ifp-fmt-xml-wrap">
					<input type="radio" name="export_format" value="xml" id="ifp-fmt-xml" form="ifp-export-form">
					<span class="ifp-format-label">
						<span class="ifp-format-icon">🗂️</span>
						<span class="ifp-format-text"><strong>XML (.xml)</strong></span>
					</span>
				</label>
			</div>
			<div class="ifp-shell__footer-actions">
				<span class="ifp-h2-count"><?php
					echo wp_kses_post( sprintf(
						/* translators: 1: number of published posts, 2: post type label */
						_n(
							'<strong>%1$d published</strong> %2$s will be exported',
							'<strong>%1$d published</strong> %2$s will be exported',
							$published, 'ideo-field-porter'
						),
						$published, esc_html( $cpt_label )
					) );
				?></span>
				<?php submit_button(
					__( '▼ Download file', 'ideo-field-porter' ),
					'primary ifp-submit',
					'submit',
					false,
					array( 'id' => 'ifp-export-btn', 'form' => 'ifp-export-form' )
				); ?>
			</div>
		</div>
		<?php endif; ?>
		<?php
	}

	/**
	 * Build the ordered list of exportable columns for the current post type.
	 * Each item: [ 'key' => string, 'label' => string, 'type_label' => string ]
	 */
	private function get_export_columns_standard() {
		$columns = array();

		// WP ID (unchecked by default)
		$columns[] = array(
			'key'        => '__post_id__',
			'label'      => __( 'ID', 'ideo-field-porter' ),
			'type_label' => __( 'Post ID', 'ideo-field-porter' ),
			'sub_fields' => array(),
			'default_off'=> true,
		);

		// Title
		$columns[] = array(
			'key'        => '__title__',
			'label'      => 'Title',
			'type_label' => __( 'Post title', 'ideo-field-porter' ),
			'sub_fields' => array(),
		);

		// Taxonomies
		$tax_names = get_object_taxonomies( $this->post_type, 'objects' );
		foreach ( $tax_names as $tax ) {
			$columns[] = array(
				'key'        => '__tax_' . $tax->name . '__',
				'label'      => $tax->labels->singular_name,
				'type_label' => __( 'Taxonomy', 'ideo-field-porter' ),
				'sub_fields' => array(),
			);
		}

		// ACF fields
		if ( function_exists( 'acf_get_field_groups' ) ) {
			// Field types that are purely organizational (no stored value) and
			// therefore never make sense as an export column.
			$layout_only = array( 'accordion', 'tab', 'message', 'divider' );
			$groups = acf_get_field_groups( array( 'post_type' => $this->post_type ) );
			foreach ( $groups as $group ) {
				$fields = acf_get_fields( $group['key'] );
				if ( ! $fields ) continue;
				foreach ( $fields as $field ) {
					$field_type = isset( $field['type'] ) ? $field['type'] : 'text';
					if ( in_array( $field_type, $layout_only, true ) ) continue;
					$columns[] = array(
						'key'        => $field['key'],
						'label'      => $field['label'],
						// Always flat — a Standard export never splits a group/
						// repeater's sub-fields into separate spreadsheet columns
						// (group: one joined cell always; repeater: one joined
						// cell per instance). Declaring sub_fields here would make
						// the live table's header grid build extra leaf columns
						// that don't correspond to any real column in the row
						// data it gets back.
						'type_label' => $field_type,
						'sub_fields' => array(),
					);
				}
			}
		}

		// Featured Image
		$columns[] = array(
			'key'        => '__featured_image__',
			'label'      => 'Featured Image',
			'type_label' => __( 'Featured image (URL)', 'ideo-field-porter' ),
			'sub_fields' => array(),
		);

		return $columns;
	}

	// ── Handle: export preview (AJAX) ────────────────────────────────────────

	public function handle_preview() {
		check_ajax_referer( $this->prefix . '_preview', 'nonce' );
		if ( ! current_user_can( \IdeoFieldPorter\Settings_Page::CAP_ACCESS ) ) wp_send_json_error( 'Access denied.' );

		$raw_columns = isset( $_POST['export_columns'] ) ? sanitize_text_field( wp_unslash( $_POST['export_columns'] ) ) : '';
		$columns     = json_decode( $raw_columns, true );
		if ( ! is_array( $columns ) || empty( $columns ) ) $columns = null;
		$separators  = $this->sanitize_export_separators( isset( $_POST['sep'] ) && is_array( $_POST['sep'] ) ? array_map( 'sanitize_text_field', wp_unslash( $_POST['sep'] ) ) : array() );
		// raw=1 is the live table fetching its row data (fewer, unformatted
		// sample rows) — the plain call keeps the original 5-row formatted
		// shape for any other caller.
		$raw   = ! empty( $_POST['raw'] );
		$limit = $raw ? 3 : 5;

		$exporter = new Spreadsheet_Exporter_Standard();
		$exporter->export_separators = $separators;

		$data = $exporter->get_preview_data( $this->post_type, $columns, $limit, array(), $raw );
		wp_send_json_success( $data );
	}

	// ── Handle: export ────────────────────────────────────────────────────────



	// ── ACF helpers ───────────────────────────────────────────────────────────

	/**
	 * Get all ACF fields attached to the current post type, from all field groups.
	 * No more hardcoded group title: we match any group whose location rules target this CPT.
	 */
	private function get_acf_fields() {
		$fields = array();
		if ( ! function_exists( 'acf_get_field_groups' ) ) return $fields;

		// Get all field groups that apply to this post type
		$groups = acf_get_field_groups( array( 'post_type' => $this->post_type ) );

		foreach ( $groups as $group ) {
			$group_fields = acf_get_fields( $group['key'] );
			if ( ! $group_fields ) continue;
			foreach ( $group_fields as $field ) {
				$this->collect_acf_field( $field, $fields );
			}
		}
		return $fields;
	}

	private function collect_acf_field( $field, &$fields, $prefix = '' ) {
		$layout_only = array( 'tab', 'accordion', 'message', 'divider' );
		if ( in_array( $field['type'], $layout_only, true ) ) return;
		$label = $prefix ? $prefix . ' › ' . $field['label'] : $field['label'];
		if ( 'group' === $field['type'] ) {
			// Group as a whole (for "name : value" cell format).
			$fields[] = array(
				'key'   => $field['key'],
				'name'  => $field['name'],
				'label' => $label,
				'type'  => 'group',
			);
			// Also expose each sub-field individually.
			if ( ! empty( $field['sub_fields'] ) ) {
				foreach ( $field['sub_fields'] as $sub ) {
					$this->collect_acf_field( $sub, $fields, $label );
				}
			}
		} elseif ( 'repeater' === $field['type'] ) {
			// Repeater as a whole.
			$fields[] = array(
				'key'   => $field['key'],
				'name'  => $field['name'],
				'label' => $label,
				'type'  => 'repeater',
			);
			// Expose sub-fields so nested columns can be mapped individually.
			if ( ! empty( $field['sub_fields'] ) ) {
				foreach ( $field['sub_fields'] as $sub ) {
					$this->collect_acf_field( $sub, $fields, $label );
				}
			}
		} elseif ( 'flexible_content' === $field['type'] ) {
			// Flexible Content as a whole — the raw ACF value is JSON-encoded as-is on
			// export and json_decode()'d as-is on import (see Spreadsheet_Importer::
			// process_acf_value()), so it round-trips through update_field() unchanged.
			$fields[] = array(
				'key'   => $field['key'],
				'name'  => $field['name'],
				'label' => $label,
				'type'  => 'flexible_content',
			);
		} else {
			$fields[] = array(
				'key'   => $field['key'],
				'name'  => $field['name'],
				'label' => $label,
				'type'  => isset( $field['type'] ) ? $field['type'] : 'text',
			);
		}
	}

	/**
	 * Get all taxonomies registered for this post type.
	 *
	 * @return \WP_Taxonomy[]
	 */
	/**
	 * Detect whether an XLSX file has multi-row merged headers (hierarchical export).
	 * Heuristic: if row 0 has cells with horizontal spans (colspan > 1) or
	 * there are vertical merges starting at row 0, it's hierarchical.
	 */
	private function is_hierarchical_xlsx( $filepath, $ext ) {
		if ( 'xlsx' !== $ext ) return false;
		try {
			$zip = new \ZipArchive();
			if ( true !== $zip->open( $filepath ) ) return false;
			$sheet_xml = $zip->getFromName( 'xl/worksheets/sheet1.xml' );
			$zip->close();
			if ( ! $sheet_xml ) return false;
			// Check for mergeCells that span rows (from_row=0, to_row>0).
			preg_match_all( '/<mergeCell ref="([A-Z]+1):([A-Z]+(\d+))"/', $sheet_xml, $m, PREG_SET_ORDER );
			foreach ( $m as $merge ) {
				if ( (int) $merge[3] > 1 ) return true; // vertical merge from row 1
			}
			// Check for horizontal merges on row 1 that go across columns.
			preg_match_all( '/<mergeCell ref="([A-Z]+1):([A-Z]+1)"/', $sheet_xml, $hm, PREG_SET_ORDER );
			foreach ( $hm as $merge ) {
				if ( $merge[1] !== $merge[2] ) return true; // colspan > 1 on row 1
			}
		} catch ( \Exception $e ) {
			return false;
		}
		return false;
	}

	/**
	 * Detect whether this post type's ACF field groups contain a layout
	 * container nested inside another one (e.g. Repeater inside Group,
	 * Group inside Repeater, Flexible Content inside Repeater…). Standard
	 * mode (the only mode in this version) exports one flat row per post,
	 * so nested sub-values in these combinations aren't exported — used to
	 * show a warning on the Export tab.
	 */
	private function has_nested_acf_fields() {
		if ( ! function_exists( 'acf_get_field_groups' ) ) return false;

		$groups = acf_get_field_groups( array( 'post_type' => $this->post_type ) );
		foreach ( $groups as $group ) {
			$fields = acf_get_fields( $group['key'] );
			if ( ! $fields ) continue;
			if ( $this->fields_have_nested_containers( $fields, false ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Recursively check if fields contain a layout container inside another
	 * container. Layout containers: group, repeater, flexible_content, clone,
	 * accordion, tab.
	 *
	 * @param array $fields       List of ACF field objects.
	 * @param bool  $in_container Whether we're already inside a layout container.
	 */
	private function fields_have_nested_containers( $fields, $in_container ) {
		$container_types = array( 'group', 'repeater', 'flexible_content', 'clone', 'accordion', 'tab' );

		foreach ( $fields as $field ) {
			$type         = isset( $field['type'] ) ? $field['type'] : '';
			$is_container = in_array( $type, $container_types, true );

			// Container inside a container → nested detected.
			if ( $is_container && $in_container ) return true;

			// Recurse into sub_fields (group, repeater, accordion, tab, clone).
			if ( ! empty( $field['sub_fields'] ) ) {
				if ( $this->fields_have_nested_containers( $field['sub_fields'], $is_container || $in_container ) ) {
					return true;
				}
			}

			// Recurse into flexible_content layouts.
			if ( 'flexible_content' === $type && ! empty( $field['layouts'] ) ) {
				foreach ( $field['layouts'] as $layout ) {
					$layout_fields = isset( $layout['sub_fields'] ) ? $layout['sub_fields'] : array();
					// Each layout is itself a container.
					if ( $this->fields_have_nested_containers( $layout_fields, true ) ) {
						return true;
					}
					// If we're already in a container and flexible_content is here, it's nested.
					if ( $in_container ) return true;
				}
			}
		}
		return false;
	}

	private function get_post_type_taxonomies() {
		$taxonomy_names = get_object_taxonomies( $this->post_type, 'names' );
		if ( empty( $taxonomy_names ) ) return array();

		$taxonomies = array();
		foreach ( $taxonomy_names as $tax_name ) {
			$tax_obj = get_taxonomy( $tax_name );
			if ( $tax_obj && ! is_wp_error( $tax_obj ) ) {
				$taxonomies[] = $tax_obj;
			}
		}
		return $taxonomies;
	}

	// ── Other helpers ─────────────────────────────────────────────────────────

	private function get_column_preview( $session, $col_name ) {
		$col_idx = array_search( $col_name, $session['headers'], true );
		if ( false === $col_idx || empty( $session['preview'] ) ) return '—';
		$row = $session['preview'][0];
		return ( isset( $row[ $col_idx ] ) && '' !== $row[ $col_idx ] ) ? $row[ $col_idx ] : '—';
	}

	private function get_feedback() {
		$key  = $this->prefix . '_feedback_' . get_current_user_id();
		$data = get_transient( $key );
		if ( $data ) { delete_transient( $key ); return $data; }
		return null;
	}

	private function redirect_with_error( $base, $msg ) {
		set_transient( $this->prefix . '_feedback_' . get_current_user_id(), array( 'type' => 'error', 'messages' => array( $msg ) ), 60 );
		wp_safe_redirect( $base );
		exit;
	}
}
