HEX
Server: LiteSpeed
System: Linux node612.namehero.net 4.18.0-553.121.1.lve.el8.x86_64 #1 SMP Thu Apr 30 16:40:41 UTC 2026 x86_64
User: dfwparty (1186)
PHP: 8.3.31
Disabled: NONE
Upload Files
File: //home/dfwparty/dfwchat.net/wp-content/plugins/buddyboss-tools/sample-data/ajax.php
<?php
/**
 * Sample Data — AJAX handlers.
 *
 * Two endpoints power the React Sample Data panel:
 *   - `bb_tools_sample_data_import` — runs requested imports in one round-trip
 *   - `bb_tools_sample_data_clear`  — wipes the imported fixture data
 *
 * @package BuddyBoss\Tools
 * @since 1.0.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * AJAX: Import sample data based on the user's checkbox selections.
 *
 * Expected POST:
 *   - `_ajax_nonce`  — nonce for action `bb_tools_sample_data`
 *   - `selections[]` — array of fixture keys (e.g. ['users', 'profile', 'groups', 'g-members'])
 *
 * Response:
 *   {
 *     success: true,
 *     data: {
 *       imported: [
 *         { id: 'users',    count: 14, label: 'Members' },
 *         { id: 'profile',  count: 14, label: 'Profile fields' },
 *         ...
 *       ]
 *     }
 *   }
 *
 * @since 1.0.0
 *
 * @return void
 */
function bb_tools_sample_data_ajax_import() {
	if ( ! bb_tools_current_user_can() ) {
		wp_send_json_error( array( 'message' => __( 'Permission denied.', 'buddyboss-tools' ) ), 403 );
	}
	check_ajax_referer( 'bb_tools_sample_data', '_ajax_nonce' );

	$selections = isset( $_POST['selections'] ) && is_array( $_POST['selections'] )
		? array_map( 'sanitize_key', wp_unslash( $_POST['selections'] ) )
		: array();

	if ( empty( $selections ) ) {
		wp_send_json_error( array( 'message' => __( 'No items selected.', 'buddyboss-tools' ) ) );
	}

	// Auto-prepend dependency-chain prerequisites. The React panel mirrors
	// Figma + legacy form layout — it does NOT surface "Sample Users" or
	// "Sample Groups" toggles, even though the legacy form had them as the
	// section parent checkboxes. The 11 demo user accounts and 8 demo groups
	// are prerequisites for every downstream importer in their respective
	// sections; without them, the sub-importers silently degrade (admin-only
	// output for member dependents; zero counts for group dependents).
	// `bb_tools_dd_is_imported` short-circuits inside the foreach loop
	// below, so these prepends are idempotent across re-imports.
	$members_dependents = array( 'profile', 'friends', 'activity', 'messages' );
	if ( ! in_array( 'users', $selections, true ) && array_intersect( $selections, $members_dependents ) ) {
		array_unshift( $selections, 'users' );
	}

	$groups_dependents = array( 'g-members', 'g-activity', 'g-forums' );
	if ( ! in_array( 'groups', $selections, true ) && array_intersect( $selections, $groups_dependents ) ) {
		// Insert `groups` AFTER `users` if both are auto-prepended, so users
		// exist before groups need creator IDs to assign.
		$users_pos = array_search( 'users', $selections, true );
		if ( false !== $users_pos ) {
			array_splice( $selections, $users_pos + 1, 0, 'groups' );
		} else {
			array_unshift( $selections, 'groups' );
		}
	}

	// Map fixture key → ( label, importer callable, marker tuple for bb_tools_dd_update_import ).
	$importers = array(
		'users'      => array( __( 'Members', 'buddyboss-tools' ), 'bb_tools_dd_import_users', array( 'users', 'users' ) ),
		'profile'    => array( __( 'Profile fields', 'buddyboss-tools' ), 'bb_tools_dd_import_users_profile', array( 'users', 'xprofile' ) ),
		'friends'    => array( __( 'Connections', 'buddyboss-tools' ), 'bb_tools_dd_import_users_friends', array( 'users', 'friends' ) ),
		'activity'   => array( __( 'Activity posts', 'buddyboss-tools' ), 'bb_tools_dd_import_users_activity', array( 'users', 'activity' ) ),
		'messages'   => array( __( 'Private messages', 'buddyboss-tools' ), 'bb_tools_dd_import_users_messages', array( 'users', 'messages' ) ),
		'groups'     => array( __( 'Groups', 'buddyboss-tools' ), 'bb_tools_dd_import_groups', array( 'groups', 'groups' ) ),
		'g-members'  => array( __( 'Group members', 'buddyboss-tools' ), 'bb_tools_dd_import_groups_members', array( 'groups', 'members' ) ),
		'g-activity' => array( __( 'Group activity', 'buddyboss-tools' ), 'bb_tools_dd_import_groups_activity', array( 'groups', 'activity' ) ),
		'g-forums'   => array( __( 'Group forums', 'buddyboss-tools' ), 'bb_tools_dd_import_forums_in_groups', array( 'groups', 'forums' ) ),
		'forums'     => array( __( 'Forums', 'buddyboss-tools' ), 'bb_tools_dd_import_forums', array( 'forums', 'forums' ) ),
		'f-topics'   => array( __( 'Discussions', 'buddyboss-tools' ), 'bb_tools_dd_import_forums_topics', array( 'forums', 'topics' ) ),
		'f-replies'  => array( __( 'Replies', 'buddyboss-tools' ), 'bb_tools_dd_import_forums_topics_replies', array( 'forums', 'replies' ) ),
	);

	$imported = array();
	foreach ( $selections as $key ) {
		if ( ! isset( $importers[ $key ] ) ) {
			continue;
		}
		list( $label, $callable, $marker ) = $importers[ $key ];

		if ( ! is_callable( $callable ) ) {
			$imported[] = array(
				'id'      => $key,
				'label'   => $label,
				'count'   => null,
				'success' => false,
				'message' => __( 'Importer not available.', 'buddyboss-tools' ),
			);
			continue;
		}

		// Skip already-imported items (idempotent).
		if ( function_exists( 'bb_tools_dd_is_imported' ) && bb_tools_dd_is_imported( $marker[0], $marker[1] ) ) {
			$imported[] = array(
				'id'      => $key,
				'label'   => $label,
				'count'   => 0,
				'success' => true,
				'message' => __( 'Already imported — skipped.', 'buddyboss-tools' ),
			);
			continue;
		}

		try {
			$result = call_user_func( $callable );
			$count  = is_array( $result ) ? count( $result ) : (int) $result;

			if ( function_exists( 'bb_tools_dd_update_import' ) ) {
				bb_tools_dd_update_import( $marker[0], $marker[1] );
			}

			$imported[] = array(
				'id'      => $key,
				'label'   => $label,
				'count'   => $count,
				'success' => true,
				'message' => sprintf(
					/* translators: 1: count, 2: label */
					__( '%1$d %2$s imported successfully', 'buddyboss-tools' ),
					$count,
					$label
				),
			);
		} catch ( \Throwable $e ) {
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated on WP_DEBUG.
				error_log( sprintf( 'BB Tools: Sample Data import "%s" failed: %s', $key, $e->getMessage() ) );
			}
			$imported[] = array(
				'id'      => $key,
				'label'   => $label,
				'count'   => 0,
				'success' => false,
				'message' => $e->getMessage(),
			);
		}
	}

	// Recalculate bbPress aggregate counters for every imported forum + topic.
	// Mirrors the legacy block in bp_admin_tools_default_data_save() (lines
	// 115-131 of bp-core-tools-default-data.php). Without this, newly imported
	// forums display "0 topics / 0 replies" in bbPress UIs until another bbPress
	// hook recounts them.
	if ( function_exists( 'bp_is_active' ) && bp_is_active( 'forums' ) ) {
		$forum_ids = bp_get_option( 'bp_dd_imported_forum_ids', array() );
		if ( ! empty( $forum_ids ) && function_exists( 'bbp_update_forum_topic_count' ) && function_exists( 'bbp_update_forum_reply_count' ) ) {
			foreach ( (array) $forum_ids as $forum_id ) {
				bbp_update_forum_topic_count( $forum_id );
				bbp_update_forum_reply_count( $forum_id );
			}
		}

		$topic_ids = bp_get_option( 'bp_dd_imported_topic_ids', array() );
		if ( ! empty( $topic_ids ) && function_exists( 'bbp_update_topic_reply_count' ) ) {
			foreach ( (array) $topic_ids as $topic_id ) {
				bbp_update_topic_reply_count( $topic_id );
			}
		}
	}

	wp_send_json_success( array( 'imported' => $imported ) );
}
add_action( 'wp_ajax_bb_tools_sample_data_import', 'bb_tools_sample_data_ajax_import' );

/**
 * AJAX: Clear all imported sample data.
 *
 * Expected POST:
 *   - `_ajax_nonce` — nonce for action `bb_tools_sample_data`
 *
 * @since 1.0.0
 *
 * @return void
 */
function bb_tools_sample_data_ajax_clear() {
	if ( ! bb_tools_current_user_can() ) {
		wp_send_json_error( array( 'message' => __( 'Permission denied.', 'buddyboss-tools' ) ), 403 );
	}
	check_ajax_referer( 'bb_tools_sample_data', '_ajax_nonce' );

	if ( function_exists( 'bb_tools_dd_clear_db' ) ) {
		bb_tools_dd_clear_db();
	}

	wp_send_json_success( array( 'message' => __( 'Sample data cleared.', 'buddyboss-tools' ) ) );
}
add_action( 'wp_ajax_bb_tools_sample_data_clear', 'bb_tools_sample_data_ajax_clear' );

/**
 * Ordered delete plan for the chunked clear endpoint.
 *
 * Each step is `[ option_key, per_id_callable, chunk_size ]`. The endpoint
 * walks the steps in order, processes one chunk from the first non-empty
 * option-array per request, shrinks the option as it goes, and returns
 * `done:true` only after every option-array is empty AND the marker options
 * have been wiped. Chunk sizes are tuned so each request finishes well
 * under the FastCGI 30s idle timeout on a small install:
 *
 *   - replies    : ~50ms each → 30 per chunk ≈ 1.5s
 *   - topics     : ~50ms each → 30 per chunk ≈ 1.5s
 *   - forums     : ~80ms each → 30 per chunk ≈ 2.4s (wp_delete_post + bbp_delete_forum)
 *   - groups     : ~100ms each → 20 per chunk ≈ 2.0s
 *   - xprofile   : ~50ms each → 30 per chunk ≈ 1.5s
 *   - members    : ~500ms each (bp_core_delete_account) → 5 per chunk ≈ 2.5s
 *
 * @since 1.0.0
 *
 * @return array<int, array{0:string, 1:callable, 2:int}>
 */
function bb_tools_sample_data_get_clear_plan() {
	return array(
		array( 'bp_dd_imported_reply_ids', 'wp_delete_post', 30 ),
		array( 'bp_dd_imported_topic_ids', 'wp_delete_post', 30 ),
		array(
			'bp_dd_imported_forum_ids',
			function ( $forum_id ) {
				wp_delete_post( $forum_id );
				if ( function_exists( 'bbp_delete_forum' ) ) {
					bbp_delete_forum( $forum_id );
				}
			},
			30,
		),
		array( 'bp_dd_imported_group_ids', 'groups_delete_group', 20 ),
		array( 'bp_dd_imported_user_xprofile_ids', 'xprofile_delete_field_group', 30 ),
		array( 'bp_dd_imported_user_ids', 'bp_core_delete_account', 5 ),
	);
}

/**
 * AJAX: Clear sample data — one chunk of one delete-step per request.
 *
 * The React panel polls this endpoint until the response contains
 * `done:true`. The server picks the first non-empty option-array from the
 * delete plan, processes `chunk_size` IDs through that step's callable,
 * writes the shrunk option back, and returns. When every option-array is
 * empty, the marker options are wiped and `done:true` is returned.
 *
 * Splitting the work this way keeps each PHP request under the FastCGI
 * idle timeout (30s MAMP, 60s typical PHP-FPM) even on installs with
 * thousands of imported records — `bp_core_delete_account` alone runs
 * ~500ms per user, so 11 users + heavy data quickly exceeds the timeout.
 *
 * Expected POST:
 *   - `_ajax_nonce` — nonce for action `bb_tools_sample_data`
 *
 * Response:
 *   {
 *     success: true,
 *     data: {
 *       done:           false,                 // true = nothing left to clear
 *       step:           'bp_dd_imported_reply_ids',
 *       processed:      30,
 *       remaining_step: 145,                   // after this chunk, this step still has 145 IDs
 *     }
 *   }
 *
 * @since 1.0.0
 *
 * @return void
 */
function bb_tools_sample_data_ajax_clear_chunk() {
	if ( ! bb_tools_current_user_can() ) {
		wp_send_json_error( array( 'message' => __( 'Permission denied.', 'buddyboss-tools' ) ), 403 );
	}
	check_ajax_referer( 'bb_tools_sample_data', '_ajax_nonce' );

	if ( function_exists( 'set_time_limit' ) ) {
		set_time_limit( 0 );
	}
	ignore_user_abort( true );

	$plan = bb_tools_sample_data_get_clear_plan();

	foreach ( $plan as $step ) {
		list( $option_key, $callable, $chunk_size ) = $step;

		$ids = (array) bp_get_option( $option_key, array() );
		if ( empty( $ids ) ) {
			continue;
		}

		// Re-key so array_slice with numeric offsets behaves predictably,
		// then take the first $chunk_size IDs.
		$ids       = array_values( $ids );
		$batch     = array_slice( $ids, 0, (int) $chunk_size );
		$remaining = array_slice( $ids, count( $batch ) );

		foreach ( $batch as $id ) {
			try {
				call_user_func( $callable, $id );
			} catch ( \Throwable $e ) {
				if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
					// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated on WP_DEBUG.
					error_log( sprintf( 'BB Tools: Sample Data clear "%s id %s" failed: %s', $option_key, (string) $id, $e->getMessage() ) );
				}
			}
		}

		if ( empty( $remaining ) ) {
			bp_delete_option( $option_key );
		} else {
			bp_update_option( $option_key, $remaining );
		}

		// One chunk per request — the client polls back for the next one.
		wp_send_json_success(
			array(
				'done'           => false,
				'step'           => $option_key,
				'processed'      => count( $batch ),
				'remaining_step' => count( $remaining ),
			)
		);
	}

	// Every data list is empty — wipe the cross-cutting marker options
	// (`bp_dd_import_users/_groups/_forums` + `_imported_forums_group_ids` +
	// `_imported_user_messages_ids`) so the React panel re-enables every
	// checkbox and disables the Clear button on its next render.
	if ( function_exists( 'bb_tools_dd_delete_import_records' ) ) {
		bb_tools_dd_delete_import_records();
	}

	wp_send_json_success(
		array(
			'done' => true,
		)
	);
}
add_action( 'wp_ajax_bb_tools_sample_data_clear_chunk', 'bb_tools_sample_data_ajax_clear_chunk' );

/**
 * Item-key → display-label map. Single source of truth shared by the
 * AJAX response messages, the localize blob shipped to the React panel
 * (window.bbToolsSampleDataConfig.itemLabels), and any future consumer.
 *
 * @since 1.0.0
 *
 * @return array<string, string>
 */
function bb_tools_sample_data_get_item_labels() {
	$importers = bb_tools_sample_data_get_importers();
	$labels    = array();
	foreach ( $importers as $key => $tuple ) {
		$labels[ $key ] = $tuple[0];
	}
	return $labels;
}

/**
 * Item-key → bool map of which items have already been imported.
 *
 * Mirrors the legacy form's `bp_dd_imported_disabled()` helper which echoed
 * `disabled="disabled" checked="checked"` for any item whose marker exists
 * in `bp_dd_import_<group>`. The React panel reads this from the localize
 * blob (window.bbToolsSampleDataConfig.alreadyImported) and renders the
 * matching checkbox as checked + disabled.
 *
 * @since 1.0.0
 *
 * @return array<string, bool>
 */
function bb_tools_sample_data_get_already_imported() {
	if ( ! function_exists( 'bb_tools_dd_is_imported' ) ) {
		return array();
	}

	$importers = bb_tools_sample_data_get_importers();
	$out       = array();
	foreach ( $importers as $key => $tuple ) {
		$marker      = $tuple[2];
		$out[ $key ] = (bool) bb_tools_dd_is_imported( $marker[0], $marker[1] );
	}
	return $out;
}

/**
 * Importers map shared by the per-item AJAX handler, the legacy batch
 * handler, the item-label helper, and the already-imported helper.
 * Each entry: `[ label, importer callable, marker tuple ]`. The marker
 * tuple maps to the legacy `bb_tools_dd_is_imported( $group, $item )` /
 * `bb_tools_dd_update_import( $group, $item )` pair.
 *
 * @since 1.0.0
 *
 * @return array<string, array{0:string, 1:string, 2:array{0:string, 1:string}}>
 */
function bb_tools_sample_data_get_importers() {
	return array(
		'users'      => array( __( 'Members', 'buddyboss-tools' ), 'bb_tools_dd_import_users', array( 'users', 'users' ) ),
		'profile'    => array( __( 'Profile fields', 'buddyboss-tools' ), 'bb_tools_dd_import_users_profile', array( 'users', 'xprofile' ) ),
		'friends'    => array( __( 'Connections', 'buddyboss-tools' ), 'bb_tools_dd_import_users_friends', array( 'users', 'friends' ) ),
		'activity'   => array( __( 'Activity posts', 'buddyboss-tools' ), 'bb_tools_dd_import_users_activity', array( 'users', 'activity' ) ),
		'messages'   => array( __( 'Private messages', 'buddyboss-tools' ), 'bb_tools_dd_import_users_messages', array( 'users', 'messages' ) ),
		'groups'     => array( __( 'Groups', 'buddyboss-tools' ), 'bb_tools_dd_import_groups', array( 'groups', 'groups' ) ),
		'g-members'  => array( __( 'Group members', 'buddyboss-tools' ), 'bb_tools_dd_import_groups_members', array( 'groups', 'members' ) ),
		'g-activity' => array( __( 'Group activity', 'buddyboss-tools' ), 'bb_tools_dd_import_groups_activity', array( 'groups', 'activity' ) ),
		'g-forums'   => array( __( 'Group forums', 'buddyboss-tools' ), 'bb_tools_dd_import_forums_in_groups', array( 'groups', 'forums' ) ),
		'forums'     => array( __( 'Forums', 'buddyboss-tools' ), 'bb_tools_dd_import_forums', array( 'forums', 'forums' ) ),
		'f-topics'   => array( __( 'Discussions', 'buddyboss-tools' ), 'bb_tools_dd_import_forums_topics', array( 'forums', 'topics' ) ),
		'f-replies'  => array( __( 'Replies', 'buddyboss-tools' ), 'bb_tools_dd_import_forums_topics_replies', array( 'forums', 'replies' ) ),
	);
}

/**
 * Per-item chunk policy for the chunked AJAX importer.
 *
 * MAMP's FastCGI has a 30s idle timeout and production PHP-FPM typically
 * uses 60s — `set_time_limit( 0 )` doesn't help, because the timeout is on
 * the FastCGI pipe being silent, not on PHP's own clock. So any importer
 * whose full run can exceed the smaller of those numbers must split its
 * work across multiple HTTP round-trips. Each entry below caps one round-
 * trip to a slice small enough to finish in well under 30s on a typical
 * install:
 *
 *   - `g-activity`: 150-row plan, ~600ms per row → 30 rows / chunk ≈ 18s
 *   - `f-replies` : ~100 topics × 1–7 replies × ~50ms → 30 topics / chunk ≈ 10s
 *
 * `total_cb` returns the upper bound for that item so the client can detect
 * `done`. For `g-activity` the cap is hardcoded in the importer's for-loop.
 * For `f-replies` the cap is the count of imported topics (the slice target).
 *
 * `chunked_cb` adapts the importer's call signature. The two chunked
 * importers expose `$offset`/`$limit` in different positions
 * (g-activity: positions 0/1; f-replies: positions 1/2 after `$topics`), so
 * the AJAX layer can't blindly forward args. Each adapter receives
 * `( $offset, $chunk_size )` and is responsible for routing them correctly.
 *
 * `count_option` (optional) names a WP option whose array size represents the
 * cumulative imported count. When present, the AJAX layer reports the per-
 * chunk delta (size after − size before) instead of the importer's own return
 * value. This is needed when an importer returns the full accumulated ID
 * array on every call (the legacy contract) rather than a per-call count.
 *
 * Items not listed here run unchunked in a single round-trip.
 *
 * @since 1.0.0
 *
 * @return array<string, array{chunk:int, total_cb:callable, chunked_cb:callable, count_option?:string}>
 */
function bb_tools_sample_data_get_chunk_policy() {
	return array(
		'g-activity' => array(
			'chunk'      => 30,
			'total_cb'   => function () {
				return 150;
			},
			'chunked_cb' => function ( $offset, $limit ) {
				return bb_tools_dd_import_groups_activity( $offset, $limit );
			},
		),
		'f-replies'  => array(
			'chunk'        => 30,
			'total_cb'     => function () {
				return count( (array) bp_get_option( 'bp_dd_imported_topic_ids', array() ) );
			},
			'chunked_cb'   => function ( $offset, $limit ) {
				return bb_tools_dd_import_forums_topics_replies( false, $offset, $limit );
			},
			'count_option' => 'bp_dd_imported_reply_ids',
		),
	);
}

/**
 * AJAX: Import a single sample-data item (chunked architecture).
 *
 * The React panel fires this endpoint sequentially — one HTTP round-trip per
 * selected item, or per sub-batch for chunkable items. Splitting heavy
 * importers keeps each PHP request well under the FastCGI idle timeout
 * (30s MAMP, 60s typical PHP-FPM). `set_time_limit( 0 )` doesn't help with
 * FastCGI idle timeout, so sub-batching is the only real fix.
 *
 * Expected POST:
 *   - `_ajax_nonce` — nonce for action `bb_tools_sample_data`
 *   - `item`        — fixture key (one of: users, profile, friends, activity,
 *                     messages, groups, g-members, g-activity, g-forums,
 *                     forums, f-topics, f-replies)
 *   - `offset`      — (chunkable items only) zero-based start index for this
 *                     sub-batch. Default 0.
 *
 * Response on success:
 *   {
 *     success: true,
 *     data: {
 *       id:          'g-activity',
 *       label:       'Group activity',
 *       count:       30,        // rows produced in THIS sub-batch
 *       success:     true,
 *       message:     '30 Group activity imported successfully',
 *       done:        false,     // (chunkable) more sub-batches remain
 *       next_offset: 30         // (chunkable) start index for next call
 *     }
 *   }
 *
 * @since 1.0.0
 *
 * @return void
 */
function bb_tools_sample_data_ajax_import_one() {
	if ( ! bb_tools_current_user_can() ) {
		wp_send_json_error( array( 'message' => __( 'Permission denied.', 'buddyboss-tools' ) ), 403 );
	}
	check_ajax_referer( 'bb_tools_sample_data', '_ajax_nonce' );

	// Lift PHP execution-time limit. Note: this does NOT defeat FastCGI idle
	// timeout — sub-batching (see chunk_policy) is what handles that.
	if ( function_exists( 'set_time_limit' ) ) {
		set_time_limit( 0 );
	}
	ignore_user_abort( true );

	$item = isset( $_POST['item'] ) ? sanitize_key( wp_unslash( $_POST['item'] ) ) : '';
	if ( empty( $item ) ) {
		wp_send_json_error( array( 'message' => __( 'No item specified.', 'buddyboss-tools' ) ) );
	}

	$importers = bb_tools_sample_data_get_importers();
	if ( ! isset( $importers[ $item ] ) ) {
		wp_send_json_error( array( 'message' => __( 'Unknown item.', 'buddyboss-tools' ) ) );
	}

	list( $label, $callable, $marker ) = $importers[ $item ];

	if ( ! is_callable( $callable ) ) {
		wp_send_json_error(
			array(
				'id'      => $item,
				'label'   => $label,
				'success' => false,
				'message' => __( 'Importer not available.', 'buddyboss-tools' ),
			)
		);
	}

	$policy     = bb_tools_sample_data_get_chunk_policy();
	$is_chunked = isset( $policy[ $item ] );
	$offset     = $is_chunked && isset( $_POST['offset'] ) ? absint( wp_unslash( $_POST['offset'] ) ) : 0;
	$chunk_size = $is_chunked ? (int) $policy[ $item ]['chunk'] : 0;
	$total      = $is_chunked ? (int) call_user_func( $policy[ $item ]['total_cb'] ) : 0;

	// Idempotent: skip items already fully imported (marker present). For
	// chunked items, the marker is only written on the final sub-batch — so
	// a mid-flight chunked run won't false-positive here.
	if ( function_exists( 'bb_tools_dd_is_imported' ) && bb_tools_dd_is_imported( $marker[0], $marker[1] ) ) {
		wp_send_json_success(
			array_merge(
				array(
					'id'      => $item,
					'label'   => $label,
					'count'   => 0,
					'success' => true,
					'message' => __( 'Already imported — skipped.', 'buddyboss-tools' ),
				),
				$is_chunked ? array(
					'done'        => true,
					'next_offset' => $total,
				) : array()
			)
		);
	}

	try {
		if ( $is_chunked ) {
			// For importers whose return value is the FULL accumulated ID
			// array (legacy contract — see bb_tools_dd_import_forums_topics_replies),
			// compute the per-chunk count from the option's size delta instead
			// of the importer's return value. Otherwise sum-on-client would
			// over-count across chunks.
			$count_option = isset( $policy[ $item ]['count_option'] ) ? (string) $policy[ $item ]['count_option'] : '';
			$before       = '' !== $count_option ? count( (array) bp_get_option( $count_option, array() ) ) : 0;

			$result = call_user_func( $policy[ $item ]['chunked_cb'], $offset, $chunk_size );

			if ( '' !== $count_option ) {
				$after = count( (array) bp_get_option( $count_option, array() ) );
				$count = max( 0, $after - $before );
			} else {
				$count = is_array( $result ) ? count( $result ) : (int) $result;
			}

			$next_offset = $offset + $chunk_size;
			$done        = $next_offset >= $total;
		} else {
			$result = call_user_func( $callable );
			$count  = is_array( $result ) ? count( $result ) : (int) $result;
			$done   = true;
		}

		// Mark + recount only on the final pass. For unchunked items that's
		// the only pass; for chunked items it's when offset+chunk reaches total.
		if ( $done ) {
			if ( function_exists( 'bb_tools_dd_update_import' ) ) {
				bb_tools_dd_update_import( $marker[0], $marker[1] );
			}

			// Count one usage increment per imported item, on its completing
			// pass only (not per chunk). $label is the item's human name.
			if ( function_exists( 'bb_record_tool_usage' ) ) {
				bb_record_tool_usage( 'sample_data', $item, $label );
			}

			// Mirror the legacy bbPress recount block from the batch handler.
			if ( in_array( $item, array( 'forums', 'f-topics', 'f-replies' ), true ) ) {
				if ( function_exists( 'bp_is_active' ) && bp_is_active( 'forums' ) ) {
					$forum_ids = bp_get_option( 'bp_dd_imported_forum_ids', array() );
					if ( ! empty( $forum_ids ) && function_exists( 'bbp_update_forum_topic_count' ) && function_exists( 'bbp_update_forum_reply_count' ) ) {
						foreach ( (array) $forum_ids as $forum_id ) {
							bbp_update_forum_topic_count( $forum_id );
							bbp_update_forum_reply_count( $forum_id );
						}
					}
					$topic_ids = bp_get_option( 'bp_dd_imported_topic_ids', array() );
					if ( ! empty( $topic_ids ) && function_exists( 'bbp_update_topic_reply_count' ) ) {
						foreach ( (array) $topic_ids as $topic_id ) {
							bbp_update_topic_reply_count( $topic_id );
						}
					}
				}
			}
		}

		$payload = array(
			'id'      => $item,
			'label'   => $label,
			'count'   => $count,
			'success' => true,
			'message' => sprintf(
				/* translators: 1: count, 2: label */
				__( '%1$d %2$s imported successfully', 'buddyboss-tools' ),
				$count,
				$label
			),
		);

		if ( $is_chunked ) {
			$payload['done']        = $done;
			$payload['next_offset'] = $done ? $total : $next_offset;
		}

		wp_send_json_success( $payload );
	} catch ( \Throwable $e ) {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated on WP_DEBUG.
			error_log( sprintf( 'BB Tools: Sample Data import "%s" failed: %s', $item, $e->getMessage() ) );
		}
		wp_send_json_error(
			array(
				'id'      => $item,
				'label'   => $label,
				'count'   => 0,
				'success' => false,
				'message' => sprintf(
					/* translators: %s: import item label */
					__( 'Error importing %s. Please try again.', 'buddyboss-tools' ),
					$label
				),
				'detail'  => defined( 'WP_DEBUG' ) && WP_DEBUG ? $e->getMessage() : '',
			)
		);
	}
}
add_action( 'wp_ajax_bb_tools_sample_data_import_one', 'bb_tools_sample_data_ajax_import_one' );