<?php
/**
 * Fundraiser Meta Boxes
 *
 * @package AmbassadorHub\Fundraisers\Admin
 */

namespace AmbassadorHub\Fundraisers\Admin;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use AmbassadorHub\Accounts\Admin\Settings\SettingsManager;
use AmbassadorHub\Admin\InlineAssets;
use AmbassadorHub\Donations\DonationsModule;
use AmbassadorHub\Donors\DonorsModule;
use AmbassadorHub\Fundraisers\FundraisersHelpers;
use AmbassadorHub\Fundraisers\FundraisersModule;
use AmbassadorHub\Fundraisers\Admin\Settings\SettingsManager as FundraiserSettingsManager;
use AmbassadorHub\Admin\DateTimeHelper;

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare

/**
 * Registers and renders meta boxes for the fundraiser post type.
 *
 * Covers the fundraiser edit screen (post.php / post-new.php):
 * - Campaign, Owner, Target Amount, Donations, Child Fundraisers meta boxes
 * - The Fundraisers meta box added to the campaign edit screen
 * - Meta save handler
 * - Admin notice for target-amount validation
 */
final class MetaBoxes {

	/**
	 * Initialize meta box hooks.
	 *
	 * @return void
	 */
	public static function init(): void {
		add_action( 'save_post_' . FundraisersModule::POST_TYPE, array( self::class, 'save_fundraiser_meta' ) );
		add_action( 'admin_notices', array( self::class, 'display_admin_notices' ) );

		// Fix Publish metabox status display for the pending_verification custom status.
		// WordPress core only shows labels for built-in statuses; these two hooks bridge the gap.
		add_action( 'post_submitbox_misc_actions', array( self::class, 'inject_pending_status_input' ) );
		add_action( 'admin_footer-post.php', array( self::class, 'inject_pending_status_js' ) );
		add_action( 'admin_footer-post-new.php', array( self::class, 'inject_pending_status_js' ) );

		// Inline CSS/JS for the donations/children meta boxes shown on the
		// fundraiser detail/stats page — must be enqueued early enough for
		// admin_print_styles, so hooked on admin_enqueue_scripts rather than
		// called from the meta box render callbacks themselves.
		add_action( 'admin_enqueue_scripts', array( self::class, 'enqueue_stats_metabox_assets' ) );
	}

	/**
	 * Enqueue the shared inline CSS/JS used by the donations and children
	 * meta boxes on the fundraiser detail/stats page.
	 *
	 * Hooked on 'admin_enqueue_scripts', which fires early enough for
	 * wp_add_inline_style()/wp_add_inline_script() to still be picked up by
	 * admin_print_styles/admin_print_footer_scripts.
	 *
	 * @return void
	 */
	public static function enqueue_stats_metabox_assets(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$page            = isset( $_GET['page'] ) ? sanitize_key( $_GET['page'] ) : '';
		$stats_page_slug = FundraisersModule::STATS_PAGE_SLUG;

		if ( ! $page || $stats_page_slug !== $page ) {
			return;
		}

		InlineAssets::style(
			'.fundraiser-donations .wp-list-table { margin-top: 10px; }
				.ambh-donation-comment-icon { position: relative; display: inline-block; margin-left: 5px; vertical-align: middle; cursor: pointer; color: #2271b1; }
				.ambh-donation-comment-icon .dashicons { font-size: 16px; width: 16px; height: 16px; line-height: 1; }
				.ambh-donation-comment-tooltip { display: none !important; }
				.ambh-anon-reveal { background: none !important; border: none !important; padding: 0; margin-left: 4px; cursor: pointer; color: #2271b1; vertical-align: middle; outline: none !important; box-shadow: none !important; }
				.ambh-anon-reveal:focus, .ambh-anon-reveal:active { outline: none !important; box-shadow: none !important; border: none !important; background: none !important; }
				.ambh-anon-reveal .dashicons { font-size: 15px; width: 15px; height: 15px; line-height: 1; vertical-align: middle; }
				#ambh-global-comment-tooltip { display: none; position: fixed; background: #1d2327; color: #fff; padding: 6px 10px; border-radius: 4px; font-size: 12px; line-height: 1.5; white-space: pre-wrap; max-width: 360px; z-index: 99999; pointer-events: none; box-shadow: 0 2px 8px rgba(0,0,0,.35); }
				#ambh-global-comment-tooltip::after { content: \'\'; position: absolute; top: 100%; left: 50%; transform: translateX(-50%); border: 5px solid transparent; border-top-color: #1d2327; }'
		);

		InlineAssets::script(
			"(function() {
			document.addEventListener( 'DOMContentLoaded', function () {
				if ( document.getElementById( 'ambh-global-comment-tooltip' ) ) return;
				var tip = document.createElement( 'div' );
				tip.id = 'ambh-global-comment-tooltip';
				document.body.appendChild( tip );

				function show( icon ) {
					var src = icon.querySelector( '.ambh-donation-comment-tooltip' );
					if ( ! src ) return;
					tip.textContent = src.textContent;
					tip.style.display = 'block';
					var r = icon.getBoundingClientRect();
					var tw = tip.offsetWidth;
					var left = r.left + r.width / 2 - tw / 2;
					left = Math.max( 8, Math.min( left, window.innerWidth - tw - 8 ) );
					tip.style.left = left + 'px';
					tip.style.top  = ( r.top - tip.offsetHeight - 8 ) + 'px';
				}

				document.addEventListener( 'mouseover', function ( e ) {
					var icon = e.target.closest( '.ambh-donation-comment-icon' );
					if ( icon ) show( icon );
				} );
				document.addEventListener( 'mouseout', function ( e ) {
					if ( e.target.closest( '.ambh-donation-comment-icon' ) ) tip.style.display = 'none';
				} );

				// Eye icon toggle for anonymous donors.
				document.addEventListener( 'click', function ( e ) {
					var btn = e.target.closest( '.ambh-anon-reveal' );
					if ( ! btn ) return;
					var row      = btn.closest( 'tr' );
					var label    = row.querySelector( '.ambh-anon-label' );
					var actuals  = row.querySelectorAll( '.ambh-anon-actual' );
					var dashes   = row.querySelectorAll( '.ambh-anon-dash' );
					var icon     = btn.querySelector( '.dashicons' );
					var revealed = btn.dataset.revealed === '1';
					if ( revealed ) {
						if ( label ) label.style.display = '';
						actuals.forEach( function ( el ) { el.style.display = 'none'; } );
						dashes.forEach( function ( el ) { el.style.display = ''; } );
						icon.classList.replace( 'dashicons-hidden', 'dashicons-visibility' );
						btn.title = btn.getAttribute( 'data-title-show' ) || btn.title;
						btn.dataset.revealed = '0';
					} else {
						if ( label ) label.style.display = 'none';
						actuals.forEach( function ( el ) { el.style.display = ''; } );
						dashes.forEach( function ( el ) { el.style.display = 'none'; } );
						icon.classList.replace( 'dashicons-visibility', 'dashicons-hidden' );
						btn.dataset.revealed = '1';
					}
				} );
			} );
		}());"
		);

		InlineAssets::style(
			'.child-fundraisers .wp-list-table { margin-top: 10px; }
				.fundraiser-status { display: inline-block; padding: 3px 8px; border-radius: 3px; font-size: 12px; font-weight: 500; }
				.fundraiser-status-publish { background: #d4edda; color: #155724; }
				.fundraiser-status-draft { background: #f0f0f1; color: #646970; }
				.fundraiser-status-pending_verification { background: #fff3cd; color: #856404; }'
		);
	}

	// -------------------------------------------------------------------------
	// Publish metabox — custom status display fix
	// -------------------------------------------------------------------------

	/**
	 * Inject a hidden input for the pending_verification status.
	 *
	 * WordPress tracks the current status via input[name="hidden_post_status"].
	 * For custom statuses the core meta-box does not output this value, so
	 * clicking "Update" without touching the status dropdown would silently
	 * revert to "draft". This hook ensures the correct value is present.
	 *
	 * @param \WP_Post $post The current post object.
	 *
	 * @return void
	 */
	public static function inject_pending_status_input( \WP_Post $post ): void {
		if ( FundraisersModule::POST_TYPE !== $post->post_type ) {
			return;
		}
		if ( 'pending_verification' !== $post->post_status ) {
			return;
		}
		echo '<input type="hidden" name="hidden_post_status" value="pending_verification">';
	}

	/**
	 * Inject JavaScript that fixes the Publish metabox status display.
	 *
	 * WordPress core only populates #post-status-display for built-in statuses.
	 * This script:
	 * 1. Adds a "Pending Verification" option to the #post_status select if absent.
	 * 2. Selects it and updates the display span when the post currently has that status.
	 *
	 * @return void
	 */
	public static function inject_pending_status_js(): void {
		global $post;
		if ( ! $post || FundraisersModule::POST_TYPE !== $post->post_type ) {
			return;
		}
		$label      = esc_js( __( 'Pending Verification', 'ambassadorhub' ) );
		$is_current = ( 'pending_verification' === $post->post_status ) ? 'true' : 'false';

		InlineAssets::script(
			"jQuery( function( \$ ) {
			var label = '" . $label . "';
			var \$select = \$( 'select#post_status' );

			// Add the option if the dropdown does not already include it.
			if ( \$select.find( 'option[value=\"pending_verification\"]' ).length === 0 ) {
				\$select.append( \$( '<option>', { value: 'pending_verification', text: label } ) );
			}

			// If this is the post's current status, select it and update the display label.
			if ( " . $is_current . " ) {
				\$select.val( 'pending_verification' );
				\$( '#post-status-display' ).text( label );
			}
		} );"
		);
	}

	// -------------------------------------------------------------------------
	// Renderers
	// -------------------------------------------------------------------------

	/**
	 * Render owner meta box.
	 *
	 * @param \WP_Post $post Current post object.
	 * @return void
	 */
	public static function render_owner_meta_box( \WP_Post $post ): void {
		$selected_user = $post->post_author;

		$users = get_users(
			array(
				'orderby' => 'display_name',
				'order'   => 'ASC',
			)
		);

		echo '<select name="post_author_override" style="width: 100%;">';
		echo '<option value="">' . esc_html__( '-- Select Owner --', 'ambassadorhub' ) . '</option>';

		foreach ( $users as $user ) {
			$selected = ( (int) $selected_user === (int) $user->ID ) ? 'selected' : '';
			echo '<option value="' . esc_attr( $user->ID ) . '" ' . esc_attr( $selected ) . '>' . esc_html( $user->display_name ) . ' (' . esc_html( $user->user_email ) . ')</option>';
		}

		echo '</select>';
	}

	/**
	 * Render target amount meta box.
	 *
	 * @param \WP_Post $post Current post object.
	 * @return void
	 */
	public static function render_target_meta_box( \WP_Post $post ): void {
		$target_amount = get_post_meta( $post->ID, '_ambh_target_amount', true );

		echo '<p>';
		/* translators: %s: currency symbol */
		echo '<label for="target_amount" style="display: block; font-weight: 600;">' . esc_html( sprintf( __( 'Target Amount', 'ambassadorhub' ) ) ) . '</label><br>';
		echo '<input type="text" inputmode="decimal" name="target_amount" id="target_amount" value="' . esc_attr( FundraiserSettingsManager::format_amount_for_input( floatval( $target_amount ) ) ) . '" class="ambh-currency-input" style="width: 100%;">';
		FundraiserSettingsManager::enqueue_currency_input_assets();
		echo '</p>';
	}

	/**
	 * Render fundraiser teams meta box.
	 *
	 * Controls whether this fundraiser allows nested sub-teams to be created
	 * beneath it. Only shown when the fundraiser's campaign has teams enabled.
	 *
	 * @param \WP_Post $post Current post object.
	 * @return void
	 */
	public static function render_teams_meta_box( \WP_Post $post ): void {
		$allow_nested = get_post_meta( $post->ID, '_ambh_allow_nested_team', true ) === '1';

		wp_nonce_field( 'fundraiser_teams_nonce', 'fundraiser_teams_nonce' );
		?>
		<p>
			<label>
				<input
					type="checkbox"
					name="_ambh_allow_nested_team"
					value="1"
					<?php checked( $allow_nested ); ?>
				/>
				<?php esc_html_e( 'Allow ambassadors to create a sub-team inside this fundraiser', 'ambassadorhub' ); ?>
			</label>
		</p>
		<p class="description">
			<?php esc_html_e( 'When enabled, ambassadors who join this fundraiser as a team member can themselves start a nested sub-team beneath it.', 'ambassadorhub' ); ?>
		</p>
		<?php
	}

	/**
	 * Render the team fundraiser type meta box.
	 *
	 * @param \WP_Post $post Current post object.
	 * @return void
	 */
	public static function render_is_team_meta_box( \WP_Post $post ): void {
		$is_team = get_post_meta( $post->ID, FundraisersModule::META_IS_TEAM, true ) === '1';
		wp_nonce_field( 'fundraiser_is_team_nonce', 'fundraiser_is_team_nonce' );
		?>
		<p>
			<label>
				<input
					type="checkbox"
					name="_ambh_is_team"
					value="1"
					<?php checked( $is_team ); ?>
				/>
				<?php esc_html_e( 'This is a team fundraiser', 'ambassadorhub' ); ?>
			</label>
		</p>
		<p class="description">
			<?php esc_html_e( 'Team fundraisers are shown with a team icon in the admin list.', 'ambassadorhub' ); ?>
		</p>
		<?php
	}

	/**
	 * Render donations meta box.
	 *
	 * @param \WP_Post $post Current post object.
	 * @return void
	 */
	public static function render_donations_meta_box( \WP_Post $post ): void {
		global $wpdb;
		$table_name   = DonationsModule::get_table_name();
		$donors_table = DonorsModule::get_table_name();

		$child_ids      = self::get_fundraiser_children_ids_recursive( $post->ID );
		$fundraiser_ids = array_merge( array( $post->ID ), $child_ids );

		// Build placeholders for IN clause.
		$placeholders = implode( ', ', array_fill( 0, count( $fundraiser_ids ), '%d' ) );

		// LEFT JOIN donors so that donor name/email are available even though PII
		// was removed from the donations table by the RemoveDonorPiiColumns migration.
		$sql       = "SELECT d.*,
			dn.first_name AS donor_first_name,
			dn.last_name  AS donor_last_name,
			dn.name       AS donor_name,
			dn.email      AS donor_email
		FROM {$table_name} d
		LEFT JOIN {$donors_table} dn ON dn.id = d.donor_id
		WHERE d.fundraiser_id IN ({$placeholders})
		ORDER BY d.created_at DESC";
		$donations = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
			$wpdb->prepare( $sql, ...$fundraiser_ids )
		);

		// Sorting — 'd_' prefix avoids collision with other sortable tables on the same page.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$raw_d_orderby = isset( $_GET['d_orderby'] ) ? sanitize_key( wp_unslash( $_GET['d_orderby'] ) ) : 'created_at';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$raw_d_order = isset( $_GET['d_order'] ) ? strtolower( sanitize_key( wp_unslash( $_GET['d_order'] ) ) ) : 'desc';
		$d_orderby   = in_array( $raw_d_orderby, array( 'donor_name', 'amount', 'status', 'created_at' ), true ) ? $raw_d_orderby : 'created_at';
		$d_order     = 'asc' === $raw_d_order ? 'asc' : 'desc';
		$d_sort_base = add_query_arg(
			array(
				'page'    => FundraisersModule::STATS_PAGE_SLUG,
				'post_id' => $post->ID,
			),
			admin_url( 'admin.php' )
		);
		$d_sort_link = function ( string $label, string $col ) use ( $d_orderby, $d_order, $d_sort_base ): string {
			$next = ( $d_orderby === $col && 'asc' === $d_order ) ? 'desc' : 'asc';
			$ind  = $d_orderby === $col
				? '&nbsp;<span aria-hidden="true">' . ( 'asc' === $d_order ? '&#9650;' : '&#9660;' ) . '</span>'
				: '';
			return '<a href="' . esc_url(
				add_query_arg(
					array(
						'd_orderby' => $col,
						'd_order'   => $next,
					),
					$d_sort_base
				)
			) . '">' . esc_html( $label ) . $ind . '</a>';
		};

		if ( ! empty( $donations ) ) {
			usort(
				$donations,
				function ( $a, $b ) use ( $d_orderby, $d_order ): int {
					$va = match ( $d_orderby ) {
						'donor_name' => strtolower( $a->donor_name ?? '' ),
						'amount'     => (float) $a->amount,
						'status'     => $a->status ?? '',
						default      => $a->created_at ?? '',
					};
					$vb = match ( $d_orderby ) {
						'donor_name' => strtolower( $b->donor_name ?? '' ),
						'amount'     => (float) $b->amount,
						'status'     => $b->status ?? '',
						default      => $b->created_at ?? '',
					};
					$cmp = is_string( $va ) ? strcmp( $va, $vb ) : ( $va <=> $vb );
					return 'desc' === $d_order ? -$cmp : $cmp;
				}
			);
		}

		?>
		<div class="fundraiser-donations">
			<?php if ( empty( $donations ) ) : ?>
				<p><?php echo esc_html__( 'No donations yet for this fundraiser.', 'ambassadorhub' ); ?></p>
			<?php else : ?>
				<table class="wp-list-table widefat fixed striped">
					<thead>
						<tr>
							<th><?php echo esc_html__( 'Fundraiser Name', 'ambassadorhub' ); ?></th>
							<th style="white-space:nowrap"><?php echo wp_kses_post( $d_sort_link( __( 'Donor Name', 'ambassadorhub' ), 'donor_name' ) ); ?></th>
							<th><?php echo esc_html__( 'Email', 'ambassadorhub' ); ?></th>
							<th style="white-space:nowrap"><?php echo wp_kses_post( $d_sort_link( __( 'Amount', 'ambassadorhub' ), 'amount' ) ); ?></th>
							<th style="white-space:nowrap"><?php echo wp_kses_post( $d_sort_link( __( 'Status', 'ambassadorhub' ), 'status' ) ); ?></th>
							<th style="white-space:nowrap"><?php echo wp_kses_post( $d_sort_link( __( 'Date', 'ambassadorhub' ), 'created_at' ) ); ?></th>
						</tr>
					</thead>
					<tbody>
						<?php
						$total_amount = 0;
						foreach ( $donations as $donation ) :
							if ( 'completed' === $donation->status ) {
								$total_amount += $donation->amount;
							}
							?>
							<tr>
								<td>
								<?php
									$fundraiser = get_post( $donation->fundraiser_id );
								if ( $fundraiser ) {
									echo esc_html( $fundraiser->post_title );
								} else {
									echo '—';
								}
								?>
								</td>
								<td>
									<?php
									if ( ! empty( $donation->is_anonymous ) ) {
										$anon_name_parts = array_filter(
											array(
												$donation->donor_first_name ?? '',
												$donation->donor_last_name ?? '',
											)
										);
										$anon_real_name  = $anon_name_parts ? implode( ' ', $anon_name_parts ) : ( $donation->donor_name ?? '' );
										$anon_donor_url  = ! empty( $anon_real_name )
										? add_query_arg(
											array(
												'page' => 'ambassadorhub-donors',
												's'    => $anon_real_name,
											),
											admin_url( 'admin.php' )
										)
										: '';
										echo '<span class="ambh-anon-label"><span style="color:#999;font-style:italic;">' . esc_html__( 'Anonymous', 'ambassadorhub' ) . '</span></span> ';
										echo '<span class="ambh-anon-actual" style="display:none">';
										echo $anon_donor_url ? '<a href="' . esc_url( $anon_donor_url ) . '">' . esc_html( $anon_real_name ) . '</a>' : esc_html( $anon_real_name );
										echo '</span>';
										echo ' <button type="button" class="ambh-anon-reveal button-link" title="' . esc_attr__( 'Show donor info', 'ambassadorhub' ) . '"><span class="dashicons dashicons-visibility" style="font-size:15px;width:15px;height:15px;"></span></button>';
									} else {
										$admin_name_parts = array_filter(
											array(
												$donation->donor_first_name ?? '',
												$donation->donor_last_name ?? '',
											)
										);
										$admin_name       = $admin_name_parts
											? implode( ' ', $admin_name_parts )
										: ( $donation->donor_name ?? '' );
										if ( ! empty( $admin_name ) ) {
											$donor_url = add_query_arg(
												array(
													'page' => 'ambassadorhub-donors',
													's'    => $admin_name,
												),
												admin_url( 'admin.php' )
											);
											echo '<a href="' . esc_url( $donor_url ) . '">' . esc_html( $admin_name ) . '</a>';
										} else {
											echo esc_html( $admin_name );
										}
									}
									?>
							</td>
							<td>
							<?php
							if ( ! empty( $donation->is_anonymous ) ) :
								?>
								<span class="ambh-anon-dash" style="color:#999;">&mdash;</span><span class="ambh-anon-actual" style="display:none"><?php echo esc_html( $donation->donor_email ?? '' ); ?></span>
								<?php
else :
								echo esc_html( $donation->donor_email ?? '' );
endif;
?>
</td>
								<td>
							<?php echo esc_html( FundraiserSettingsManager::format_currency( (float) $donation->amount ) ); ?>
							<?php if ( ! empty( $donation->message ) ) : ?>
								<span class="ambh-donation-comment-icon" tabindex="0">
									<span class="dashicons dashicons-format-chat" aria-hidden="true"></span>
									<span class="ambh-donation-comment-tooltip"><?php echo esc_html( $donation->message ); ?></span>
								</span>
							<?php endif; ?>
						</td>
								<td>
								<?php
								$status_colors = array(
									'completed' => 'green',
									'pending'   => 'grey',
									'failed'    => 'red',
								);
								$status_labels = array(
									'completed' => __( 'Completed', 'ambassadorhub' ),
									'pending'   => __( 'Pending', 'ambassadorhub' ),
									'failed'    => __( 'Failed', 'ambassadorhub' ),
								);
								?>
								<span class="ambh-status-pill ambh-status-pill--<?php echo esc_attr( $status_colors[ $donation->status ] ?? 'grey' ); ?>">
									<?php echo esc_html( $status_labels[ $donation->status ] ?? ucfirst( $donation->status ) ); ?>
									</span>
								</td>
								<td><?php echo esc_html( date_i18n( get_option( 'date_format' ), strtotime( $donation->created_at ) ) ); ?></td>
							</tr>
						<?php endforeach; ?>
					</tbody>
					<tfoot>
						<tr>
							<td colspan="3"><strong><?php echo esc_html__( 'Total (Completed):', 'ambassadorhub' ); ?></strong></td>
							<td colspan="3"><strong><?php echo esc_html( FundraiserSettingsManager::format_currency( $total_amount ) ); ?></strong></td>
						</tr>
					</tfoot>
				</table>
			<?php endif; ?>
		</div>
		<?php
	}

	/**
	 * Render child fundraisers meta box.
	 *
	 * @param \WP_Post $post Current post object.
	 * @return void
	 */
	public static function render_children_meta_box( \WP_Post $post ): void {
		global $wpdb;
		$table_name = DonationsModule::get_table_name();

		$child_ids = self::get_fundraiser_children_ids_recursive( $post->ID );

		if ( empty( $child_ids ) ) {
			/* translators: %s: child fundraiser depth label plural (lowercase) */
			echo '<p>' . esc_html__( 'This fundraiser has no children.', 'ambassadorhub' ) . '</p>';
			return;
		}

		// Sorting — 't_' prefix avoids collision with the donations table on the same page.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$raw_t_orderby = isset( $_GET['t_orderby'] ) ? sanitize_key( wp_unslash( $_GET['t_orderby'] ) ) : 'title';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$raw_t_order = isset( $_GET['t_order'] ) ? strtolower( sanitize_key( wp_unslash( $_GET['t_order'] ) ) ) : 'asc';
		$t_orderby   = in_array( $raw_t_orderby, array( 'title', 'raised' ), true ) ? $raw_t_orderby : 'title';
		$t_order     = 'desc' === $raw_t_order ? 'desc' : 'asc';
		$t_sort_base = add_query_arg(
			array(
				'page'    => FundraisersModule::STATS_PAGE_SLUG,
				'post_id' => $post->ID,
			),
			admin_url( 'admin.php' )
		);
		$t_sort_link = function ( string $label, string $col ) use ( $t_orderby, $t_order, $t_sort_base ): string {
			$next = ( $t_orderby === $col && 'asc' === $t_order ) ? 'desc' : 'asc';
			$ind  = $t_orderby === $col
				? '&nbsp;<span aria-hidden="true">' . ( 'asc' === $t_order ? '&#9650;' : '&#9660;' ) . '</span>'
				: '';
			return '<a href="' . esc_url(
				add_query_arg(
					array(
						't_orderby' => $col,
						't_order'   => $next,
					),
					$t_sort_base
				)
			) . '">' . esc_html( $label ) . $ind . '</a>';
		};

		$children = get_posts(
			array(
				'post_type'      => FundraisersModule::POST_TYPE,
				'post__in'       => $child_ids,
				'posts_per_page' => -1,
				'orderby'        => 'title',
				'order'          => 'ASC',
				'post_status'    => 'any',
			)
		);

		// Collect all row data first so we can sort by any column.
		$rows = array();
		foreach ( $children as $child ) {
			$owner_id         = (int) get_post_field( 'post_author', $child->ID );
			$ambassador_name  = '—';
			$ambassador_email = '—';
			if ( $owner_id ) {
				$user = get_user_by( 'ID', $owner_id );
				if ( $user ) {
					$ambassador_name  = $user->display_name;
					$ambassador_email = $user->user_email;
				}
			}

			$child_fundraiser_ids = array_merge(
				array( $child->ID ),
				self::get_fundraiser_children_ids_recursive( $child->ID )
			);
			$placeholders         = implode( ', ', array_fill( 0, count( $child_fundraiser_ids ), '%d' ) );
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table_name is built from $wpdb->prefix (safe); $placeholders contains only literal %d tokens (safe).
			$sql    = "SELECT COALESCE(SUM(amount), 0) FROM $table_name WHERE fundraiser_id IN ($placeholders) AND status = 'completed'";
			$raised = (float) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$wpdb->prepare( $sql, ...$child_fundraiser_ids )
			);
			$level  = self::get_fundraiser_level( $child->ID, $post->ID );
			$rows[] = compact( 'child', 'ambassador_name', 'ambassador_email', 'raised', 'level' );
		}

		usort(
			$rows,
			function ( array $a, array $b ) use ( $t_orderby, $t_order ): int {
				$va  = 'raised' === $t_orderby ? $a['raised'] : strtolower( $a['child']->post_title );
				$vb  = 'raised' === $t_orderby ? $b['raised'] : strtolower( $b['child']->post_title );
				$cmp = is_string( $va ) ? strcmp( $va, $vb ) : ( $va <=> $vb );
				return 'desc' === $t_order ? -$cmp : $cmp;
			}
		);

		$total_raised = array_sum( array_column( $rows, 'raised' ) );

		?>
		<div class="child-fundraisers">
			<table class="wp-list-table widefat fixed striped">
				<thead>
					<tr>
						<th style="white-space:nowrap"><?php echo wp_kses_post( $t_sort_link( __( 'Fundraiser', 'ambassadorhub' ), 'title' ) ); ?></th>
						<th><?php echo esc_html__( 'Ambassador', 'ambassadorhub' ); ?></th>
						<th><?php echo esc_html__( 'Email', 'ambassadorhub' ); ?></th>
						<th style="white-space:nowrap"><?php echo wp_kses_post( $t_sort_link( __( 'Raised', 'ambassadorhub' ), 'raised' ) ); ?></th>
					</tr>
				</thead>
				<tbody>
					<?php
					foreach ( $rows as $row ) :
						$child            = $row['child'];
						$ambassador_name  = $row['ambassador_name'];
						$ambassador_email = $row['ambassador_email'];
						$raised           = $row['raised'];
						$indent           = str_repeat( '&nbsp;&nbsp;&nbsp;', $row['level'] );
						?>
						<tr>
							<td>
								<?php echo wp_kses_post( $indent ); ?>
								<a href="
								<?php
								echo esc_url(
									add_query_arg(
										array(
											'page'    => FundraisersModule::STATS_PAGE_SLUG,
											'post_id' => $child->ID,
										),
										admin_url( 'admin.php' )
									)
								);
								?>
											">
									<?php echo esc_html( $child->post_title ); ?>
								</a>
							</td>
							<td><?php echo esc_html( $ambassador_name ); ?></td>
							<td><?php echo esc_html( $ambassador_email ); ?></td>
							<td><?php echo esc_html( FundraiserSettingsManager::format_currency( $raised ) ); ?></td>
						</tr>
					<?php endforeach; ?>
				</tbody>
				<tfoot>
					<tr style="background: #f9f9f9; font-weight: bold;">
						<td colspan="3"><?php echo esc_html__( 'Total:', 'ambassadorhub' ); ?></td>
						<td><?php echo esc_html( FundraiserSettingsManager::format_currency( $total_raised ) ); ?></td>
					</tr>
				</tfoot>
			</table>
		</div>
		<?php
	}

	/**
	 * Render fundraiser end date meta box.
	 *
	 * Shown only when the campaign allows ambassadors to set an end date.
	 * Validates that the chosen date does not exceed the campaign end date (if set).
	 *
	 * @param \WP_Post $post Current post object.
	 * @return void
	 */
	public static function render_dates_meta_box( \WP_Post $post ): void {
		wp_nonce_field( 'fundraiser_dates_nonce', 'fundraiser_dates_nonce' );

		$current_end_gmt = (string) get_post_meta( $post->ID, FundraisersModule::META_END_DATE, true );
		$current_value   = $current_end_gmt ? DateTimeHelper::format_datetime_for_input( $current_end_gmt ) : '';

		$root_id      = FundraisersHelpers::get_root_fundraiser_id( $post->ID );
		$root_end_gmt = $root_id > 0 ? (string) get_post_meta( $root_id, '_ambh_end_date', true ) : '';
		$max_value    = $root_end_gmt ? DateTimeHelper::format_datetime_for_input( $root_end_gmt ) : '';

		?>
		<p>
			<label for="ambh_end_date"><?php esc_html_e( 'End date', 'ambassadorhub' ); ?></label><br>
			<input
				type="datetime-local"
				id="ambh_end_date"
				name="_ambh_end_date"
				value="<?php echo esc_attr( $current_value ); ?>"
				<?php
				if ( $max_value ) :
					?>
					max="<?php echo esc_attr( $max_value ); ?>"<?php endif; ?>
				style="width: 100%;"
			>
		</p>
		<?php if ( $max_value ) : ?>
		<p class="description">
			<?php
			$local_max = DateTimeHelper::convert_gmt_to_local( $root_end_gmt );
			printf(
				/* translators: %s: root fundraiser end date in site timezone */
				esc_html__( 'Must be on or before the root fundraiser end date: %s.', 'ambassadorhub' ),
				esc_html( date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), strtotime( $local_max ) ) )
			);
			?>
		</p>
		<?php endif; ?>
		<p class="description">
			<?php esc_html_e( 'Leave empty to keep the fundraiser open until the root fundraiser ends.', 'ambassadorhub' ); ?>
		</p>
		<?php
	}

	// -------------------------------------------------------------------------
	// Save handler
	// -------------------------------------------------------------------------

	/**
	 * Save fundraiser meta data.
	 *
	 * @param int $post_id Post ID.
	 * @return void
	 */
	public static function save_fundraiser_meta( int $post_id ): void {
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}

		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return;
		}

		if ( ! isset( $_POST['fundraiser_meta_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['fundraiser_meta_nonce'] ) ), 'fundraiser_meta_nonce' ) ) {
			return;
		}

		// Note: Owner (post_author) is handled automatically by WordPress via post_author_override.

		// Save allow-nested-team flag (fundraiser teams meta box).
		if ( isset( $_POST['fundraiser_teams_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['fundraiser_teams_nonce'] ) ), 'fundraiser_teams_nonce' ) ) {
			$allow_nested_team = isset( $_POST['_ambh_allow_nested_team'] ) ? '1' : '0';
			update_post_meta( $post_id, FundraisersModule::META_ALLOW_NESTED, $allow_nested_team );
		}

		// Save is-team flag (fundraiser type meta box).
		if ( isset( $_POST['fundraiser_is_team_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['fundraiser_is_team_nonce'] ) ), 'fundraiser_is_team_nonce' ) ) {
			$is_team = isset( $_POST['_ambh_is_team'] ) ? '1' : '0';
			update_post_meta( $post_id, FundraisersModule::META_IS_TEAM, $is_team );
		}

		// Save fundraiser end date.
		if ( isset( $_POST['fundraiser_dates_nonce'] ) && wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['fundraiser_dates_nonce'] ) ), 'fundraiser_dates_nonce' ) ) {
			$end_date_input = sanitize_text_field( wp_unslash( $_POST['_ambh_end_date'] ?? '' ) );
			if ( $end_date_input ) {
				$end_date_gmt = DateTimeHelper::convert_datetime_to_gmt( $end_date_input );

				// Validate against root fundraiser end date — child end date must not exceed it.
				$root_id      = FundraisersHelpers::get_root_fundraiser_id( $post_id );
				$root_end_gmt = $root_id > 0 ? (string) get_post_meta( $root_id, '_ambh_end_date', true ) : '';
				if ( $root_end_gmt && $end_date_gmt > $root_end_gmt ) {
					$end_date_gmt = $root_end_gmt;
				}

				update_post_meta( $post_id, FundraisersModule::META_END_DATE, $end_date_gmt );
			} else {
				delete_post_meta( $post_id, FundraisersModule::META_END_DATE );
			}
		}

		// Save target amount.
		if ( isset( $_POST['target_amount'] ) ) {
			$target_amount = FundraiserSettingsManager::normalize_amount( sanitize_text_field( wp_unslash( $_POST['target_amount'] ) ) );

			// If the fundraiser has a parent, the child cannot exceed the parent's target.
			$fundraiser = get_post( $post_id );
			if ( $fundraiser && $fundraiser->post_parent ) {
				$parent_target = (float) get_post_meta( $fundraiser->post_parent, '_ambh_target_amount', true );

				if ( $parent_target > 0 && $target_amount > $parent_target ) {
					add_settings_error(
						'fundraiser_target',
						'target_exceeds_parent',
						sprintf(
							/* translators: %s: formatted maximum parent target amount */
							__( 'Het doel kan niet hoger zijn dan het doel van de bovenliggende fundraiser (%s). Het doel is aangepast.', 'ambassadorhub' ),
							FundraiserSettingsManager::format_currency( $parent_target )
						),
						'warning'
					);
					$target_amount = $parent_target;
				}
			}

			update_post_meta( $post_id, '_ambh_target_amount', $target_amount );
		}
	}

	/**
	 * Display admin notices for fundraiser operations.
	 *
	 * @return void
	 */
	public static function display_admin_notices(): void {
		settings_errors( 'fundraiser_target' );
	}

	// -------------------------------------------------------------------------
	// Private helpers
	// -------------------------------------------------------------------------

	/**
	 * Get all child fundraiser IDs recursively.
	 *
	 * @param int $fundraiser_id Parent fundraiser ID.
	 * @return array All descendant fundraiser IDs.
	 */
	private static function get_fundraiser_children_ids_recursive( int $fundraiser_id ): array {
		$all_children = array();

		$children = get_posts(
			array(
				'post_type'      => FundraisersModule::POST_TYPE,
				'post_parent'    => $fundraiser_id,
				'posts_per_page' => -1,
				'fields'         => 'ids',
				'post_status'    => 'any',
			)
		);

		$all_children = array_merge( $all_children, $children );

		foreach ( $children as $child_id ) {
			$descendants  = self::get_fundraiser_children_ids_recursive( $child_id );
			$all_children = array_merge( $all_children, $descendants );
		}

		return $all_children;
	}

	/**
	 * Get the nesting level of a fundraiser relative to a given ancestor.
	 *
	 * @param int $fundraiser_id Fundraiser ID to check.
	 * @param int $ancestor_id   Ancestor ID to measure from.
	 * @return int Nesting level (0 = direct child).
	 */
	private static function get_fundraiser_level( int $fundraiser_id, int $ancestor_id ): int {
		$level      = 0;
		$current_id = $fundraiser_id;

		while ( $current_id !== $ancestor_id ) {
			$fundraiser = get_post( $current_id );
			if ( ! $fundraiser || ! $fundraiser->post_parent ) {
				break;
			}
			$current_id = $fundraiser->post_parent;
			++$level;

			if ( $level > 10 ) {
				break;
			}
		}

		return $level;
	}
}
