<?php
/**
 * Fundraisers Module
 *
 * Handles fundraiser custom post type with hierarchical structure.
 *
 * @package AmbassadorHub\Fundraisers
 */

namespace AmbassadorHub\Fundraisers;

use AmbassadorHub\Admin\InlineAssets;
use AmbassadorHub\Frontend\BlockTemplateLoader;
use AmbassadorHub\Accounts\Admin\Settings\SettingsManager as AccountsSettings;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Fundraisers Module class.
 */
class FundraisersModule {

	public const POST_TYPE = 'ambh_fundraiser';

	/**
	 * Slug of the hidden fundraiser detail/stats admin page.
	 *
	 * A single literal constant rather than POST_TYPE . '_stats' built at
	 * each call site: functionally identical (same 'ambh_fundraiser_stats'
	 * value — keep this in sync with POST_TYPE by hand if that ever
	 * changes), but a static analyzer scanning for un-prefixed globals can't
	 * evaluate string concatenation, so it kept flagging the bare '_stats'
	 * fragment as if it were the whole, unprefixed slug.
	 */
	public const STATS_PAGE_SLUG = 'ambh_fundraiser_stats';

	/** Post meta key that marks a fundraiser as a team. */
	public const META_IS_TEAM = '_ambh_is_team';

	/** Post meta key on campaigns that controls whether teams are enabled. */
	public const META_TEAMS_ENABLED = '_ambh_teams_enabled';

	/** Post meta key on fundraisers that controls whether a nested sub-team may be created inside them. */
	public const META_ALLOW_NESTED = '_ambh_allow_nested_team';

	/** Post meta key that stores the fundraiser's own end date (GMT Y-m-d H:i:s). */
	public const META_END_DATE = '_ambh_end_date';

	/** Post meta key on root fundraisers: whether ambassadors may set an end date on their fundraisers. */
	public const META_FUNDRAISER_END_DATE_ENABLED = '_ambh_fundraiser_end_date_enabled';

	/** Post meta key on root fundraisers: overall financial goal. */
	public const META_FINANCIAL_GOAL = '_ambh_financial_goal';

	/** Post meta key on root fundraisers: whether the root fundraiser itself accepts direct donations. */
	public const META_ACCEPTS_DONATIONS = '_ambh_accepts_donations';

	/**
	 * Initialize the module.
	 *
	 * @return void
	 */
	public static function init(): void {

		// Register custom post type and status.
		add_action( 'init', array( self::class, 'register_post_type' ) );

		// Activation hook to flush rewrite rules and perform other setup tasks.
		add_action( 'ambh_activate', array( self::class, 'register_post_type' ) );

		// Register post meta for REST API exposure (root-fundraiser config fields).
		add_action( 'init', array( self::class, 'register_post_meta' ) );

		// Register capabilities.
		FundraisersCapabilities::register();

		// Initialize admin menu and meta boxes.
		Admin\Menu::init();
		Admin\FundraiserAttributesMetaBox::init();
		Admin\MetaBoxes::init();
		Admin\RootFundraiserMetaBoxes::init();
		Admin\ListTable::init();
		Admin\Settings\SettingsManager::init();

		// Initialize cache.
		FundraiserCache::init();

		// Automations for both fundraiser and donation domain events.
		// DonationsAutomations is registered here (not in DonationsModule) because
		// its triggers are scoped to the fundraiser entity.
		FundraisersAutomations::init();
		\AmbassadorHub\Donations\DonationsAutomations::init();

		// Register fundraiser migrations.
		MigrationRunner::init();

		// Initialize REST API.
		FundraisersApi::init();

		// Allow the fundraiser author to view their own draft on the frontend.
		add_action( 'pre_get_posts', array( self::class, 'allow_author_to_preview_draft' ) );

		// Include 'pending_verification' fundraisers in the native WP parent selector
		// (Page Attributes meta box). wp_dropdown_pages() defaults to post_status=publish.
		add_filter( 'page_attributes_dropdown_pages_args', array( self::class, 'include_pending_in_parent_dropdown' ), 10, 2 );

		// Swap page title to "Edit …" when ?edit_fundraiser_id is set on the create page.
		add_action( 'wp', array( self::class, 'maybe_filter_edit_page_title' ) );

		// Show a draft notice banner to the author when viewing their own draft.
		add_action( 'wp_footer', array( self::class, 'render_draft_notice' ) );

		// Inline CSS for the draft notice banner — must be enqueued before
		// wp_head fires, so hooked on wp_enqueue_scripts rather than called
		// from render_draft_notice() itself (which runs in wp_footer).
		add_action( 'wp_enqueue_scripts', array( self::class, 'enqueue_draft_notice_style' ) );

		// Show the current ambassador's fundraisers on the account dashboard.
		add_action( 'ambh_account_dashboard', array( self::class, 'render_account_dashboard_fundraisers' ), 20 );

		// Team member invitations.
		add_filter( 'ambh_page_installer_pages', array( self::class, 'register_join_team_page' ) );
		add_action( 'admin_post_ambh_send_member_invite', array( self::class, 'handle_send_invite' ) );
		add_action( 'admin_post_nopriv_ambh_send_member_invite', array( self::class, 'handle_invite_not_logged_in' ) );

		// Register the FSE/classic page template and all Gutenberg blocks.
		// Register the FSE/classic page template — deferred to 'init' so __() does
		// not fire during plugins_loaded and trigger the just-in-time notice.
		add_action( 'init', array( self::class, 'register_templates' ), 5 );
		self::register_blocks();

		// Responsive layout overrides for the single-fundraiser template.
		add_action( 'wp_enqueue_scripts', array( self::class, 'enqueue_template_styles' ), 20 );
		add_action( 'use_block_editor_for_post_type', array( self::class, 'disable_block_editor' ), 10, 2 );

		// Fundraiser end-date scheduling and status enforcement.
		add_filter( 'wp_insert_post_data', array( self::class, 'enforce_status_policy' ), 10, 2 );
		add_action( 'save_post_' . self::POST_TYPE, array( self::class, 'handle_fundraiser_end_scheduling' ), 20 );
		add_action( 'ambh_end_fundraiser', array( self::class, 'end_fundraiser' ) );
		add_action( 'before_delete_post', array( self::class, 'handle_fundraiser_deletion_cleanup' ), 10, 2 );

		/**
		 * Fires when fundraisers module is initialized.
		 *
		 * @since 0.1.0
		 */
		do_action( 'ambh_fundraisers_module_init' );
	}

	/**
	 * Register post meta fields exposed via the REST API.
	 *
	 * Root-fundraiser config fields (_ambh_financial_goal, _ambh_teams_enabled,
	 * _ambh_fundraiser_end_date_enabled) are registered on ambh_fundraiser so they
	 * are accessible via the WP REST API on root fundraisers (post_parent=0).
	 *
	 * @return void
	 */
	public static function register_post_meta(): void {
		register_post_meta(
			self::POST_TYPE,
			self::META_FINANCIAL_GOAL,
			array(
				'type'              => 'number',
				'single'            => true,
				'default'           => 0,
				'show_in_rest'      => true,
				'sanitize_callback' => static function ( $val ) {
					return max( 0.0, (float) $val ); },
				'auth_callback'     => static function () {
					return current_user_can( 'edit_posts' ); },
			)
		);

		register_post_meta(
			self::POST_TYPE,
			self::META_TEAMS_ENABLED,
			array(
				'type'          => 'string',
				'single'        => true,
				'default'       => '0',
				'show_in_rest'  => true,
				'auth_callback' => static function () {
					return current_user_can( 'edit_posts' ); },
			)
		);

		register_post_meta(
			self::POST_TYPE,
			self::META_FUNDRAISER_END_DATE_ENABLED,
			array(
				'type'          => 'string',
				'single'        => true,
				'default'       => '0',
				'show_in_rest'  => true,
				'auth_callback' => static function () {
					return current_user_can( 'edit_posts' ); },
			)
		);

		register_post_meta(
			self::POST_TYPE,
			self::META_ACCEPTS_DONATIONS,
			array(
				'type'          => 'string',
				'single'        => true,
				'default'       => '1',
				'show_in_rest'  => true,
				'auth_callback' => static function () {
					return current_user_can( 'edit_posts' ); },
			)
		);
	}

	/**
	 * Register the fundraiser post type.
	 *
	 * @return void
	 */
	public static function register_post_type(): void {
		$labels = array(
			'name'               => __( 'Fundraisers', 'ambassadorhub' ),
			'singular_name'      => __( 'Fundraiser', 'ambassadorhub' ),
			'add_new'            => __( 'Add New', 'ambassadorhub' ),
			'add_new_item'       => __( 'Add New Fundraiser', 'ambassadorhub' ),
			'edit_item'          => __( 'Edit Fundraiser', 'ambassadorhub' ),
			'new_item'           => __( 'New Fundraiser', 'ambassadorhub' ),
			'view_item'          => __( 'View Fundraiser', 'ambassadorhub' ),
			'search_items'       => __( 'Search Fundraisers', 'ambassadorhub' ),
			'not_found'          => __( 'No fundraisers found', 'ambassadorhub' ),
			'not_found_in_trash' => __( 'No fundraisers found in trash', 'ambassadorhub' ),
			'parent_item_colon'  => __( 'Parent:', 'ambassadorhub' ),
			'menu_name'          => __( 'Fundraisers', 'ambassadorhub' ),
		);

		$args = array(
			'labels'          => $labels,
			'public'          => true,
			'has_archive'     => true,
			'hierarchical'    => true,
			'menu_icon'       => 'dashicons-heart',
			'supports'        => array( 'title', 'editor', 'page-attributes', 'thumbnail' ), // page-attributes for hierarchy, thumbnail for featured image
			'show_in_rest'    => true,
			'show_in_menu'    => false,
			'show_ui'         => true,
			'capability_type' => self::POST_TYPE,
			'map_meta_cap'    => true,
			'rewrite'         => array(
				'slug'         => 'fundraiser',
				'hierarchical' => true,
			),
		);

		register_post_type( self::POST_TYPE, $args );

		// 'pending_verification' is assigned to fundraisers created by ambassadors who
		// have not yet confirmed their email. The fundraiser auto-transitions to 'publish'
		// via EmailVerification::publish_pending_fundraisers() when they verify.
		register_post_status(
			'pending_verification',
			array(
				'label'                     => __( 'Pending Verification', 'ambassadorhub' ),
				'public'                    => false,
				'show_in_admin_all_list'    => true,
				'show_in_admin_status_list' => true,
				/* translators: %s: count of fundraisers pending verification */
				'label_count'               => _n_noop( 'Pending Verification <span class="count">(%s)</span>', 'Pending Verification <span class="count">(%s)</span>', 'ambassadorhub' ),
			)
		);

		// 'ended' is set when a fundraiser's own end date passes (via Action Scheduler)
		// or when an admin manually ends it. The fundraiser page remains publicly
		// accessible but no longer accepts new donations.
		register_post_status(
			'ended',
			array(
				'label'                     => __( 'Ended', 'ambassadorhub' ),
				'public'                    => true,
				'show_in_admin_all_list'    => true,
				'show_in_admin_status_list' => true,
				/* translators: %s: count of ended fundraisers */
				'label_count'               => _n_noop( 'Ended <span class="count">(%s)</span>', 'Ended <span class="count">(%s)</span>', 'ambassadorhub' ),
			)
		);
	}

	/**
	 * Filter the page <title> tag and on-page <h1> to say "Edit …" instead of
	 * "Create fundraiser" when the visitor lands on the create page with
	 * ?edit_fundraiser_id=X and owns that fundraiser.
	 *
	 * Hooked on 'wp' so that is_page() and the query object are available, but
	 * the filters are registered before wp_head() fires.
	 *
	 * @return void
	 */
	public static function maybe_filter_edit_page_title(): void {
		if ( is_admin() ) {
			return;
		}

		$edit_id = isset( $_GET['edit_fundraiser_id'] ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			? absint( $_GET['edit_fundraiser_id'] )      // phpcs:ignore WordPress.Security.NonceVerification.Recommended
			: 0;

		if ( $edit_id <= 0 ) {
			return;
		}

		$page_id = Admin\Settings\SettingsManager::get_create_fundraiser_page_id();
		if ( ! $page_id || ! is_page( $page_id ) ) {
			return;
		}

		$edit_post = get_post( $edit_id );
		if (
			! $edit_post
			|| self::POST_TYPE !== $edit_post->post_type
			|| get_current_user_id() !== (int) $edit_post->post_author
		) {
			return;
		}

		$edit_title = sprintf(
			/* translators: %s: fundraiser title */
			__( 'Edit: %s', 'ambassadorhub' ),
			$edit_post->post_title
		);

		// Filter the <title> tag (document_title_parts fires during wp_head).
		add_filter(
			'document_title_parts',
			static function ( array $parts ) use ( $edit_title ): array {
				$parts['title'] = $edit_title;
				return $parts;
			}
		);

		// Filter the_title() for the create-fundraiser page post only (used by
		// most themes to render the <h1> above the content).
		add_filter(
			'the_title',
			static function ( string $title, int $post_id ) use ( $edit_title, $page_id ): string {
				return ( $post_id === $page_id ) ? $edit_title : $title;
			},
			10,
			2
		);
	}

	/**
	 * Enqueue the inline CSS for the sticky draft notice banner.
	 *
	 * Mirrors the gating conditions in render_draft_notice() so the style is
	 * only enqueued when the banner will actually render. Hooked on
	 * 'wp_enqueue_scripts', which fires early enough (before wp_head) for
	 * wp_add_inline_style() to still be picked up.
	 *
	 * @return void
	 */
	public static function enqueue_draft_notice_style(): void {
		if ( ! is_singular( self::POST_TYPE ) || ! is_user_logged_in() ) {
			return;
		}

		$post = get_queried_object();
		if (
			! ( $post instanceof \WP_Post )
			|| ! in_array( $post->post_status, array( 'draft', 'pending_verification' ), true )
		) {
			return;
		}

		$current_user_id = get_current_user_id();
		$is_author       = (int) $post->post_author === $current_user_id;
		$is_admin        = current_user_can( 'edit_others_ambh_fundraisers' );

		if ( ! $is_author && ! $is_admin ) {
			return;
		}

		InlineAssets::style(
			'.ambh-draft-notice {
					position: fixed;
					bottom: 0;
					left: 0;
					right: 0;
					z-index: 9999;
					background: #1d2327;
					color: #f0f0f1;
					padding: 12px 24px;
					display: flex;
					align-items: center;
					justify-content: center;
					gap: 12px;
					font-size: 14px;
					line-height: 1.5;
					box-shadow: 0 -2px 8px rgba(0,0,0,.2);
				}
				.ambh-draft-notice__icon {
					flex-shrink: 0;
					width: 20px;
					height: 20px;
					opacity: .8;
				}
				.ambh-draft-notice a {
					color: #72aee6;
					text-decoration: underline;
				}
				.ambh-draft-notice a:hover {
					color: #a5c9f1;
				}'
		);
	}

	/**
	 * Render a sticky notice banner when the author views their own draft fundraiser
	 * on the frontend.
	 *
	 * @return void
	 */
	public static function render_draft_notice(): void {
		if ( ! is_singular( self::POST_TYPE ) || ! is_user_logged_in() ) {
			return;
		}

		$post = get_queried_object();
		if (
			! ( $post instanceof \WP_Post )
			|| ! in_array( $post->post_status, array( 'draft', 'pending_verification' ), true )
		) {
			return;
		}

		$current_user_id = get_current_user_id();
		$is_author       = (int) $post->post_author === $current_user_id;
		$is_admin        = current_user_can( 'edit_others_ambh_fundraisers' );

		if ( ! $is_author && ! $is_admin ) {
			return;
		}

		if ( 'pending_verification' === $post->post_status ) {
			$notice_message = $is_author
				? sprintf(
					/* translators: %s: link to the email verification page */
					__( 'This fundraiser is not yet published. <a href="%s">Verify your email address</a> to publish it and make it visible to others.', 'ambassadorhub' ),
					esc_url( AccountsSettings::get_account_page_url() )
				)
				: __( 'This fundraiser is pending email verification and is only visible to the creator and administrators.', 'ambassadorhub' );
		} else {
			$notice_message = __( 'This fundraiser is saved as a draft and is only visible to you.', 'ambassadorhub' );
		}

		?>
		<div class="ambh-draft-notice" role="status" aria-live="polite">
			<svg class="ambh-draft-notice__icon" viewBox="0 0 24 24" fill="none" aria-hidden="true">
				<circle cx="12" cy="12" r="10" stroke="#f0b429" stroke-width="2"/>
				<path d="M12 7v6" stroke="#f0b429" stroke-width="2" stroke-linecap="round"/>
				<circle cx="12" cy="16.5" r=".75" fill="#f0b429" stroke="#f0b429"/>
			</svg>
			<span>
				<?php echo wp_kses( $notice_message, array( 'a' => array( 'href' => array() ) ) ); ?>
			</span>
		</div>
		<?php
	}

	/**
	 * Allow a fundraiser's author to view their own draft on the frontend.
	 *
	 * Hooks into pre_get_posts for singular fundraiser requests. When the
	 * current user is the post author, WordPress's built-in "can edit this post"
	 * logic is insufficient for front-end visitors, so we explicitly add 'draft'
	 * to the accepted post statuses for the query.
	 *
	 * @param \WP_Query $query The current query.
	 *
	 * @return void
	 */
	public static function allow_author_to_preview_draft( \WP_Query $query ): void {
		if ( is_admin() || ! $query->is_main_query() || ! is_user_logged_in() ) {
			return;
		}

		$current_user_id = get_current_user_id();
		$can_edit_others = current_user_can( 'edit_others_ambh_fundraisers' );

		// Pattern A: /?p=ID — the fallback URL WordPress uses for posts without a slug.
		$p = (int) $query->get( 'p' );
		if ( $p > 0 ) {
			$post = get_post( $p );
			if (
				$post
				&& self::POST_TYPE === $post->post_type
				&& in_array( $post->post_status, array( 'draft', 'pending_verification', 'ended' ), true )
				&& ( $can_edit_others || (int) $post->post_author === $current_user_id )
			) {
				$query->set( 'post_status', array( 'publish', 'draft', 'pending_verification', 'ended' ) );
			}
			return;
		}

		// Pattern B: pretty URL — /fundraiser/my-slug/.
		// WordPress sets ambh_fundraiser and post_type query vars via the rewrite rules.
		if ( $query->get( 'post_type' ) !== self::POST_TYPE && ! $query->get( 'ambh_fundraiser' ) ) {
			return;
		}

		$slug = $query->get( 'ambh_fundraiser' );
		$slug = $slug ? $slug : $query->get( 'name' );
		if ( ! $slug ) {
			return;
		}

		// Hierarchical fundraisers produce slugs like 'parent/child' — use only the last segment.
		$slug_parts = explode( '/', trim( $slug, '/' ) );
		$slug       = end( $slug_parts );

		$posts   = get_posts(
			array(
				'name'           => $slug,
				'post_type'      => self::POST_TYPE,
				'post_status'    => array( 'publish', 'draft', 'pending_verification', 'ended' ),
				'posts_per_page' => 1,
				'fields'         => 'ids',
			)
		);
		$post_id = $posts[0] ?? 0;

		if ( ! $post_id ) {
			return;
		}

		$post = get_post( $post_id );
		if ( ! $post || ! in_array( $post->post_status, array( 'draft', 'pending_verification', 'ended' ), true ) ) {
			return;
		}

		if ( ! $can_edit_others && (int) $post->post_author !== $current_user_id ) {
			return;
		}

		$query->set( 'post_status', array( 'publish', 'draft', 'pending_verification', 'ended' ) );
	}

	/**
	 * Add 'pending_verification' to the post statuses queried for the native WP
	 * Page Attributes parent dropdown.
	 *
	 * WordPress's wp_dropdown_pages() defaults to post_status = 'publish'. This
	 * filter ensures that fundraisers awaiting email verification are also available
	 * as parent options, both in the block editor and in the classic editor meta box.
	 *
	 * @param array    $dropdown_args Args passed to wp_dropdown_pages().
	 * @param \WP_Post $post          The post being edited.
	 *
	 * @return array
	 */
	public static function include_pending_in_parent_dropdown( array $dropdown_args, \WP_Post $post ): array {
		if ( self::POST_TYPE !== $post->post_type ) {
			return $dropdown_args;
		}

		$statuses = (array) ( $dropdown_args['post_status'] ?? array( 'publish' ) );
		if ( ! in_array( 'pending_verification', $statuses, true ) ) {
			$statuses[] = 'pending_verification';
		}
		$dropdown_args['post_status'] = $statuses;

		return $dropdown_args;
	}

	/**
	 * Render the current ambassador's fundraisers in the account dashboard.
	 *
	 * @param \WP_User $user Current account user.
	 *
	 * @return void
	 */
	public static function render_account_dashboard_fundraisers( \WP_User $user ): void {
		$fundraisers     = get_posts(
			array(
				'post_type'      => self::POST_TYPE,
				'author'         => $user->ID,
				'post_status'    => array( 'publish', 'pending_verification', 'draft', 'pending', 'ended' ),
				'posts_per_page' => -1,
				'orderby'        => 'date',
				'order'          => 'DESC',
			)
		);
		$create_page_url = Admin\Settings\SettingsManager::get_create_fundraiser_page_id()
			? Admin\Settings\SettingsManager::get_create_fundraiser_page_url()
			: '';

		// Pre-classify fundraisers for tab counts.
		$type_counts = array(
			'fundraiser' => 0,
			'group'      => 0,
			'personal'   => 0,
		);
		foreach ( $fundraisers as $f ) {
			if ( get_post_meta( $f->ID, self::META_IS_TEAM, true ) === '1' ) {
				++$type_counts['group'];
			} elseif ( (int) $f->post_parent > 0 ) {
				++$type_counts['personal'];
			} else {
				++$type_counts['fundraiser'];
			}
		}
		$active_tab = 'fundraiser';
		foreach ( array( 'fundraiser', 'group', 'personal' ) as $t ) {
			if ( $type_counts[ $t ] > 0 ) {
				$active_tab = $t;
				break;
			}
		}
		$has_multiple_types = count( array_filter( $type_counts ) ) > 1;

		?>
		<section class="ambassador-account__dashboard-section ambassador-account__dashboard-section--fundraisers" aria-labelledby="ambassador-account-fundraisers-title">
			<div class="ambassador-account__dashboard-section-header">
				<div>
					<h3 id="ambassador-account-fundraisers-title"><?php esc_html_e( 'My Fundraisers', 'ambassadorhub' ); ?></h3>
				</div>
			</div>

			<?php if ( $has_multiple_types ) : ?>
				<div class="ambassador-account__fundraiser-tabs" role="tablist" aria-label="<?php esc_attr_e( 'Fundraiser types', 'ambassadorhub' ); ?>">
					<?php if ( $type_counts['fundraiser'] > 0 ) : ?>
						<button type="button" role="tab"
							class="ambassador-account__fundraiser-tab<?php echo 'fundraiser' === $active_tab ? ' ambassador-account__fundraiser-tab--active' : ''; ?>"
							aria-selected="<?php echo 'fundraiser' === $active_tab ? 'true' : 'false'; ?>"
							data-tab="fundraiser">
							<?php esc_html_e( 'My Fundraisers', 'ambassadorhub' ); ?>
							<span class="ambassador-account__fundraiser-tab-count"><?php echo (int) $type_counts['fundraiser']; ?></span>
						</button>
					<?php endif; ?>
					<?php if ( $type_counts['group'] > 0 ) : ?>
						<button type="button" role="tab"
							class="ambassador-account__fundraiser-tab<?php echo 'group' === $active_tab ? ' ambassador-account__fundraiser-tab--active' : ''; ?>"
							aria-selected="<?php echo 'group' === $active_tab ? 'true' : 'false'; ?>"
							data-tab="group">
							<?php esc_html_e( 'My Groups', 'ambassadorhub' ); ?>
							<span class="ambassador-account__fundraiser-tab-count"><?php echo (int) $type_counts['group']; ?></span>
						</button>
					<?php endif; ?>
					<?php if ( $type_counts['personal'] > 0 ) : ?>
						<button type="button" role="tab"
							class="ambassador-account__fundraiser-tab<?php echo 'personal' === $active_tab ? ' ambassador-account__fundraiser-tab--active' : ''; ?>"
							aria-selected="<?php echo 'personal' === $active_tab ? 'true' : 'false'; ?>"
							data-tab="personal">
							<?php esc_html_e( 'My pages', 'ambassadorhub' ); ?>
							<span class="ambassador-account__fundraiser-tab-count"><?php echo (int) $type_counts['personal']; ?></span>
						</button>
					<?php endif; ?>
				</div>
			<?php endif; ?>

			<?php if ( empty( $fundraisers ) ) : ?>
				<div class="ambassador-account__dashboard-empty-state">
					<p><?php esc_html_e( 'You have not created any fundraisers yet.', 'ambassadorhub' ); ?></p>
				</div>
			<?php else : ?>
				<div class="ambassador-account__fundraiser-list">
					<?php foreach ( $fundraisers as $fundraiser ) : ?>
						<?php
						$cache         = FundraiserCache::get_or_update_cache( $fundraiser->ID );
						$raised_amount = (float) ( $cache['total_amount'] ?? 0 );
						$target_amount = (float) get_post_meta( $fundraiser->ID, '_ambh_target_amount', true );
						$progress_pct  = $target_amount > 0.0
							? min( 100, round( ( $raised_amount / $target_amount ) * 100, 1 ) )
							: 0;
						$status        = get_post_status( $fundraiser );
						$status_object = $status ? get_post_status_object( $status ) : null;
						$status_label  = $status_object && ! empty( $status_object->label )
							? $status_object->label
							: ucfirst( str_replace( '_', ' ', (string) $status ) );
						$view_url      = (string) get_permalink( $fundraiser );
						$edit_url      = $create_page_url
							? add_query_arg( 'edit_fundraiser_id', $fundraiser->ID, $create_page_url )
							: '';
						$status_class  = sanitize_html_class( $status ? $status : 'unknown' );

						// Type detection.
						$is_team    = get_post_meta( $fundraiser->ID, self::META_IS_TEAM, true ) === '1';
						$has_parent = (int) $fundraiser->post_parent > 0;
						if ( $is_team ) {
							$type_label = __( 'Group', 'ambassadorhub' );
							$type_class = 'group';
						} elseif ( $has_parent ) {
							$type_label = __( 'Personal', 'ambassadorhub' );
							$type_class = 'personal';
						} else {
							$type_label = __( 'Fundraiser', 'ambassadorhub' );
							$type_class = 'fundraiser';
						}

						// Members (only for group cards).
						$members = $is_team ? get_posts(
							array(
								'post_type'      => self::POST_TYPE,
								'post_parent'    => $fundraiser->ID,
								'post_status'    => array( 'publish', 'pending_verification', 'draft', 'pending', 'ended' ),
								'posts_per_page' => -1,
								'orderby'        => 'title',
								'order'          => 'ASC',
							)
						) : array();

						$menu_id = 'ambh-fmenu-' . $fundraiser->ID;

						// Featured image.
						$thumbnail_id   = (int) get_post_thumbnail_id( $fundraiser->ID );
						$thumbnail_html = $thumbnail_id
							? wp_get_attachment_image(
								$thumbnail_id,
								'medium',
								false,
								array(
									'loading' => 'lazy',
									'alt'     => esc_attr( get_the_title( $fundraiser ) ),
								)
							)
							: '';

						// Days remaining — use the effective end date (earlier of campaign or fundraiser own end date).
						$end_date_gmt   = FundraisersHelpers::get_effective_end_date_gmt( $fundraiser->ID );
						$days_remaining = $end_date_gmt
							? (int) ceil( ( strtotime( $end_date_gmt ) - time() ) / DAY_IN_SECONDS )
							: null;
						?>
						<article class="ambassador-account__fundraiser-card<?php echo 'ended' === $status ? ' ambassador-account__fundraiser-card--ended' : ''; ?>"
							data-type="<?php echo esc_attr( $type_class ); ?>"
							data-fundraiser-id="<?php echo esc_attr( (string) $fundraiser->ID ); ?>"
							<?php
							if ( $has_multiple_types && $type_class !== $active_tab ) :
								?>
								hidden<?php endif; ?>>

							<div class="ambassador-account__fundraiser-card-image">
								<?php if ( $view_url ) : ?>
									<a href="<?php echo esc_url( $view_url ); ?>">
								<?php endif; ?>
								<?php if ( $thumbnail_html ) : ?>
									<?php echo $thumbnail_html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- wp_get_attachment_image returns safe HTML. ?>
								<?php else : ?>
									<div class="ambassador-account__fundraiser-card-placeholder" aria-hidden="true">
										<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
											<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
										</svg>
									</div>
								<?php endif; ?>
								<?php if ( $view_url ) : ?>
									</a>
								<?php endif; ?>
							</div>

							<div class="ambassador-account__fundraiser-card-body">
								<?php if ( ( 'personal' === $type_class || 'group' === $type_class ) && $fundraiser->post_parent > 0 ) : ?>
									<?php $parent_post = get_post( $fundraiser->post_parent ); ?>
									<?php if ( $parent_post ) : ?>
										<div class="ambassador-account__fundraiser-breadcrumb">
											<a href="<?php echo esc_url( (string) get_permalink( $parent_post ) ); ?>"><?php echo esc_html( get_the_title( $parent_post ) ); ?></a>
										</div>
									<?php endif; ?>
								<?php endif; ?>
								<div class="ambassador-account__fundraiser-card-header">
									<h4 class="ambassador-account__fundraiser-title">
										<?php if ( $view_url ) : ?>
											<a href="<?php echo esc_url( $view_url ); ?>"><?php echo esc_html( get_the_title( $fundraiser ) ); ?></a>
										<?php else : ?>
											<?php echo esc_html( get_the_title( $fundraiser ) ); ?>
										<?php endif; ?>
									</h4>
									<div class="ambassador-account__fundraiser-card-meta">
										<span class="ambassador-account__fundraiser-status ambassador-account__fundraiser-status--<?php echo esc_attr( $status_class ); ?>">
											<?php echo esc_html( $status_label ); ?>
										</span>
										<span class="ambassador-account__fundraiser-type ambassador-account__fundraiser-type--<?php echo esc_attr( $type_class ); ?>">
											<?php echo esc_html( $type_label ); ?>
										</span>
										<div class="ambassador-account__fundraiser-menu">
											<button type="button"
												class="ambassador-account__fundraiser-menu-trigger"
												aria-haspopup="true"
												aria-expanded="false"
												aria-controls="<?php echo esc_attr( $menu_id ); ?>"
												aria-label="<?php esc_attr_e( 'Fundraiser actions', 'ambassadorhub' ); ?>">
												<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
													<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
												</svg>
											</button>
											<div id="<?php echo esc_attr( $menu_id ); ?>"
												class="ambassador-account__fundraiser-menu-dropdown"
												role="menu"
												hidden>
												<?php if ( $view_url ) : ?>
													<a class="ambassador-account__fundraiser-menu-item"
														role="menuitem"
														href="<?php echo esc_url( $view_url ); ?>">
														<?php esc_html_e( 'Show fundraiser', 'ambassadorhub' ); ?>
													</a>
												<?php endif; ?>
												<?php if ( $edit_url ) : ?>
													<a class="ambassador-account__fundraiser-menu-item"
														role="menuitem"
														href="<?php echo esc_url( $edit_url ); ?>">
														<?php esc_html_e( 'Edit fundraiser', 'ambassadorhub' ); ?>
													</a>
												<?php endif; ?>
												<?php if ( $is_team ) : ?>
												<button type="button"
													class="ambassador-account__fundraiser-menu-item"
													data-action="invite-member"
													data-fundraiser-id="<?php echo esc_attr( (string) $fundraiser->ID ); ?>"
													role="menuitem">
													<?php esc_html_e( 'Invite member', 'ambassadorhub' ); ?>
												</button>
												<?php endif; ?>
												<button type="button" class="ambassador-account__fundraiser-menu-item ambassador-account__fundraiser-menu-item--danger" role="menuitem" data-action="close-fundraiser">
													<?php esc_html_e( 'Close fundraiser', 'ambassadorhub' ); ?>
												</button>
											</div>
										</div>
									</div>
								</div>

								<div class="ambassador-account__fundraiser-stats">
									<span class="ambassador-account__fundraiser-raised"><?php echo esc_html( self::format_dashboard_currency( $raised_amount ) ); ?></span>
									<?php if ( $target_amount > 0.0 ) : ?>
										<span class="ambassador-account__fundraiser-target">
											<?php
											echo esc_html(
												sprintf(
													/* translators: %s: formatted target amount (e.g. €1,000.00). */
													__( 'of %s', 'ambassadorhub' ),
													self::format_dashboard_currency( $target_amount )
												)
											);
											?>
										</span>
									<?php endif; ?>
									<?php if ( null !== $days_remaining && $days_remaining > 0 ) : ?>
										<span class="ambassador-account__fundraiser-days">
											<?php
											echo esc_html(
												sprintf(
													/* translators: %d: number of days remaining. */
													_n( '%d day left', '%d days left', $days_remaining, 'ambassadorhub' ),
													$days_remaining
												)
											);
											?>
										</span>
									<?php elseif ( null !== $days_remaining && $days_remaining <= 0 ) : ?>
										<span class="ambassador-account__fundraiser-days ambassador-account__fundraiser-days--ended">
											<?php esc_html_e( 'Ended', 'ambassadorhub' ); ?>
										</span>
									<?php endif; ?>
								</div>

								<?php if ( $target_amount > 0.0 ) : ?>
									<div class="ambassador-account__fundraiser-progress" role="progressbar"
										aria-valuenow="<?php echo esc_attr( (string) $progress_pct ); ?>"
										aria-valuemin="0" aria-valuemax="100">
										<div class="ambassador-account__fundraiser-progress-fill" style="width: <?php echo esc_attr( $progress_pct . '%' ); ?>;"></div>
									</div>
								<?php endif; ?>

								<?php if ( $is_team ) : ?>
									<div class="ambassador-account__fundraiser-members">
										<h5 class="ambassador-account__fundraiser-members-title"><?php esc_html_e( 'Members', 'ambassadorhub' ); ?></h5>
										<?php if ( ! empty( $members ) ) : ?>
											<ul class="ambassador-account__fundraiser-members-list">
												<?php foreach ( $members as $member ) : ?>
													<?php
													$member_view_url = (string) get_permalink( $member );
													$member_menu_id  = 'ambh-mmenu-' . $fundraiser->ID . '-' . $member->ID;
													$member_cache    = FundraiserCache::get_or_update_cache( $member->ID );
													$member_raised   = (float) ( $member_cache['total_amount'] ?? 0 );
													$member_target   = (float) get_post_meta( $member->ID, '_ambh_target_amount', true );
													$member_progress = $member_target > 0.0
														? min( 100, round( ( $member_raised / $member_target ) * 100, 1 ) )
														: 0;
													?>
													<?php $member_is_inactive = 'ended' === $member->post_status; ?>
													<li class="ambassador-account__fundraiser-member-item<?php echo $member_is_inactive ? ' ambassador-account__fundraiser-member-item--inactive' : ''; ?>">
														<span class="ambassador-account__fundraiser-member-name">
															<?php if ( $member_view_url ) : ?>
																<a href="<?php echo esc_url( $member_view_url ); ?>"><?php echo esc_html( get_the_title( $member ) ); ?></a>
															<?php else : ?>
																<?php echo esc_html( get_the_title( $member ) ); ?>
															<?php endif; ?>
															<?php if ( $member_is_inactive ) : ?>
																<span class="ambassador-account__fundraiser-member-badge ambassador-account__fundraiser-member-badge--inactive"><?php esc_html_e( 'Inactive', 'ambassadorhub' ); ?></span>
															<?php endif; ?>
														</span>
														<?php if ( $member_target > 0.0 ) : ?>
														<div class="ambassador-account__fundraiser-member-bar"
															role="progressbar"
															aria-valuenow="<?php echo esc_attr( (string) $member_progress ); ?>"
															aria-valuemin="0"
															aria-valuemax="100">
															<div class="ambassador-account__fundraiser-member-bar-fill" style="width: <?php echo esc_attr( $member_progress . '%' ); ?>;"></div>
														</div>
														<?php endif; ?>
														<span class="ambassador-account__fundraiser-member-amount"><?php echo esc_html( self::format_dashboard_currency( $member_raised ) ); ?></span>
														<div class="ambassador-account__fundraiser-menu">
															<?php if ( ! $member_is_inactive ) : ?>
															<button type="button"
																class="ambassador-account__fundraiser-menu-trigger"
																aria-haspopup="true"
																aria-expanded="false"
																aria-controls="<?php echo esc_attr( $member_menu_id ); ?>"
																aria-label="<?php esc_attr_e( 'Member actions', 'ambassadorhub' ); ?>">
																<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
																	<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"/>
																</svg>
															</button>
															<div id="<?php echo esc_attr( $member_menu_id ); ?>"
																class="ambassador-account__fundraiser-menu-dropdown"
																role="menu"
																hidden>
																<?php if ( $member_view_url ) : ?>
																	<a class="ambassador-account__fundraiser-menu-item"
																		role="menuitem"
																		href="<?php echo esc_url( $member_view_url ); ?>">
																		<?php esc_html_e( 'Show member', 'ambassadorhub' ); ?>
																	</a>
																<?php endif; ?>
																<button type="button"
																	class="ambassador-account__fundraiser-menu-item ambassador-account__fundraiser-menu-item--danger"
																	role="menuitem"
																	data-action="remove-member"
																	data-member-fundraiser-id="<?php echo esc_attr( (string) $member->ID ); ?>">
																	<?php esc_html_e( 'Remove member', 'ambassadorhub' ); ?>
																</button>
															</div>
															<?php endif; ?>
														</div>
													</li>
												<?php endforeach; ?>
											</ul>
										<?php else : ?>
											<p class="ambassador-account__fundraiser-members-empty"><?php esc_html_e( 'No members yet.', 'ambassadorhub' ); ?></p>
										<?php endif; ?>
									</div>
								<?php endif; ?>
							</div>
						</article>
						<?php if ( $is_team ) : ?>
							<?php
							// phpcs:disable WordPress.Security.NonceVerification.Recommended
							$ambh_if_id    = isset( $_GET['ambh_if_id'] ) ? (int) $_GET['ambh_if_id'] : 0;
							$ambh_if_sent  = isset( $_GET['ambh_invite_sent'] ) && $ambh_if_id === $fundraiser->ID;
							$ambh_if_error = ( isset( $_GET['ambh_invite_error'] ) && $ambh_if_id === $fundraiser->ID )
								? sanitize_key( wp_unslash( $_GET['ambh_invite_error'] ) )
								: '';
							// phpcs:enable WordPress.Security.NonceVerification.Recommended

							$ambh_if_open = $ambh_if_sent || '' !== $ambh_if_error;

							$ambh_error_messages = array(
								'rate_limit' => __( 'Too many invitations sent. Please try again later.', 'ambassadorhub' ),
								'duplicate'  => __( 'You have already invited this email address recently.', 'ambassadorhub' ),
								'invalid'    => __( 'Invalid request. Please try again.', 'ambassadorhub' ),
							);
							?>
							<div class="ambh-invite-form"
								id="ambh-invite-form-<?php echo esc_attr( (string) $fundraiser->ID ); ?>"
								<?php
								if ( ! $ambh_if_open ) :
									?>
									hidden<?php endif; ?>>
								<h5 class="ambh-invite-form__title"><?php esc_html_e( 'Invite a new member', 'ambassadorhub' ); ?></h5>
								<?php if ( $ambh_if_sent ) : ?>
									<p class="ambh-invite-form__feedback ambh-invite-form__feedback--success">
										<?php esc_html_e( 'Invitation sent!', 'ambassadorhub' ); ?>
									</p>
								<?php elseif ( '' !== $ambh_if_error ) : ?>
									<p class="ambh-invite-form__feedback ambh-invite-form__feedback--error">
										<?php echo esc_html( $ambh_error_messages[ $ambh_if_error ] ?? __( 'Something went wrong. Please try again.', 'ambassadorhub' ) ); ?>
									</p>
								<?php endif; ?>
								<?php
								$fundraiser_permalink = get_permalink( $fundraiser->ID );
								$invite_redirect_url  = $fundraiser_permalink ? $fundraiser_permalink : AccountsSettings::get_account_page_url();
								?>
								<form class="ambh-invite-form__form" method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
									<?php wp_nonce_field( 'ambh_invite_member_' . $fundraiser->ID, '_ambh_nonce' ); ?>
									<input type="hidden" name="action" value="ambh_send_member_invite">
									<input type="hidden" name="fundraiser_id" value="<?php echo esc_attr( (string) $fundraiser->ID ); ?>">
									<input type="hidden" name="redirect_url" value="<?php echo esc_url( $invite_redirect_url ); ?>">
									<p>
										<label for="ambh-invite-name-<?php echo esc_attr( (string) $fundraiser->ID ); ?>"><?php esc_html_e( 'First name', 'ambassadorhub' ); ?> <span class="required-indicator" aria-hidden="true">*</span></label>
										<input type="text" id="ambh-invite-name-<?php echo esc_attr( (string) $fundraiser->ID ); ?>" name="first_name" required autocomplete="off">
									</p>
									<p>
										<label for="ambh-invite-email-<?php echo esc_attr( (string) $fundraiser->ID ); ?>"><?php esc_html_e( 'Email address', 'ambassadorhub' ); ?> <span class="required-indicator" aria-hidden="true">*</span></label>
										<input type="email" id="ambh-invite-email-<?php echo esc_attr( (string) $fundraiser->ID ); ?>" name="email" required autocomplete="off">
									</p>
									<div class="ambh-invite-form__actions">
										<button type="submit" class="ambh-invite-form__submit"><?php esc_html_e( 'Send invitation', 'ambassadorhub' ); ?></button>
										<button type="button" class="ambh-invite-form__cancel" data-action="close-invite-form" data-fundraiser-id="<?php echo esc_attr( (string) $fundraiser->ID ); ?>"><?php esc_html_e( 'Cancel', 'ambassadorhub' ); ?></button>
									</div>
								</form>
							</div>
						<?php endif; ?>
					<?php endforeach; ?>
				</div>
				<?php if ( $has_multiple_types ) : ?>
					<p class="ambassador-account__fundraiser-tab-empty" hidden><?php esc_html_e( 'No fundraisers in this category.', 'ambassadorhub' ); ?></p>
				<?php endif; ?>
				<?php if ( $create_page_url ) : ?>
					<div class="ambh-cff-create-root-wrap">
						<a class="ambh-cff-create-root-btn"
							href="<?php echo esc_url( $create_page_url ); ?>">
							<?php esc_html_e( '+ Create new fundraiser', 'ambassadorhub' ); ?>
						</a>
					</div>
				<?php endif; ?>
			<?php endif; ?>
		</section>
		<?php
	}

	/**
	 * Format a fundraiser amount for the dashboard.
	 *
	 * @param float $amount Amount in EUR.
	 *
	 * @return string
	 */
	private static function format_dashboard_currency( float $amount ): string {
		return Admin\Settings\SettingsManager::format_currency( $amount );
	}

	/**
	 * Calculate the absolute depth of a fundraiser (1 = top-level, 2 = nested once, etc.)
	 *
	 * @param int $fundraiser_id Fundraiser ID.
	 * @return int Depth level (1-based).
	 */
	public static function calculate_fundraiser_depth( int $fundraiser_id ): int {
		return FundraisersHelpers::calculate_fundraiser_depth( $fundraiser_id );
	}

	/**
	 * Disable block editor for fundraisers (for now).
	 *
	 * @param bool   $use_block_editor Whether to use the block editor.
	 * @param string $post_type        The post type being checked.
	 *
	 * @return bool
	 */
	public static function disable_block_editor( bool $use_block_editor, string $post_type ): bool {
		if ( self::POST_TYPE === $post_type ) {
			return false;
		}

		return $use_block_editor;
	}

	/**
	 * Register the FSE/classic block template(s) for this module.
	 *
	 * @return void
	 */
	/**
	 * Register the FSE/classic block template(s) for this module.
	 *
	 * Hooked to 'init' at priority 5 — must run before BlockTemplateLoader fires
	 * at priority 20, and must not be called directly during plugins_loaded.
	 *
	 * @return void
	 */
	public static function register_templates(): void {
		BlockTemplateLoader::register(
			array(
				'slug'        => 'single-fundraiser',
				'title'       => __( 'Single Fundraiser', 'ambassadorhub' ),
				'post_types'  => array( self::POST_TYPE ),
				'path'        => AMBASSADOR_HUB_PATH . 'src/Fundraisers/block-templates/single-fundraiser.html',
				'theme_slugs' => array( 'single-fundraiser.php' ),
			)
		);

		BlockTemplateLoader::register(
			array(
				'slug'          => 'archive-fundraiser',
				'title'         => __( 'Fundraiser Archive', 'ambassadorhub' ),
				'template_type' => 'archive',
				'post_types'    => array( self::POST_TYPE ),
				'path'          => AMBASSADOR_HUB_PATH . 'src/Fundraisers/block-templates/archive-fundraiser.html',
				'theme_slugs'   => array( 'archive-fundraiser.php' ),
			)
		);
	}

	/**
	 * Prevent WordPress from silently reverting 'ended' to 'draft' during an update.
	 *
	 * WordPress sets post_status to 'draft' when a post is saved without an explicit
	 * status in some code paths. This filter restores 'ended' when that happens.
	 *
	 * @param array $data    Sanitized post data.
	 * @param array $postarr Raw post data including the post ID.
	 *
	 * @return array
	 */
	public static function enforce_status_policy( array $data, array $postarr ): array {
		if ( ( $data['post_type'] ?? '' ) !== self::POST_TYPE ) {
			return $data;
		}

		if ( 'draft' === ( $data['post_status'] ?? '' ) && ! empty( $postarr['ID'] ) ) {
			$current_status = get_post_status( (int) $postarr['ID'] );
			if ( 'ended' === $current_status ) {
				$data['post_status'] = 'ended';
			}
		}

		return $data;
	}

	/**
	 * Schedule (or unschedule) the ambh_end_fundraiser action for a fundraiser.
	 *
	 * Called on save_post_ambh_fundraiser at priority 20. Only schedules when the
	 * fundraiser has its own end date set; the campaign end date is not scheduled
	 * here (the campaign's own scheduler handles that).
	 *
	 * Also called directly by Admin\MetaBoxes::save_fundraiser_meta() after
	 * an admin updates the fundraiser dates meta box.
	 *
	 * @param int $post_id Fundraiser post ID.
	 *
	 * @return void
	 */
	public static function handle_fundraiser_end_scheduling( int $post_id ): void {
		if ( get_post_type( $post_id ) !== self::POST_TYPE ) {
			return;
		}

		// Always clear any existing scheduled job first.
		as_unschedule_all_actions( 'ambh_end_fundraiser', array( $post_id ), 'ambassadorhub' );

		// Only schedule if this fundraiser has its own end date (not just the campaign's).
		$own_end = (string) get_post_meta( $post_id, self::META_END_DATE, true );
		if ( ! $own_end ) {
			return;
		}

		$timestamp = strtotime( $own_end );
		if ( ! $timestamp || $timestamp <= time() ) {
			return;
		}

		as_schedule_single_action( $timestamp, 'ambh_end_fundraiser', array( $post_id ), 'ambassadorhub' );
	}

	/**
	 * Transition a fundraiser to the 'ended' status.
	 *
	 * Called by the ambh_end_fundraiser Action Scheduler job.
	 *
	 * @param int $post_id Fundraiser post ID.
	 *
	 * @return void
	 */
	public static function end_fundraiser( int $post_id ): void {
		$post = get_post( $post_id );
		if ( ! $post || self::POST_TYPE !== $post->post_type ) {
			return;
		}
		if ( 'ended' === $post->post_status ) {
			return; // Already ended — nothing to do.
		}

		wp_update_post(
			array(
				'ID'          => $post_id,
				'post_status' => 'ended',
			)
		);
	}

	/**
	 * Unschedule any pending ambh_end_fundraiser job when a fundraiser is deleted.
	 *
	 * @param int      $post_id Post ID.
	 * @param \WP_Post $post    Post object.
	 *
	 * @return void
	 */
	public static function handle_fundraiser_deletion_cleanup( int $post_id, \WP_Post $post ): void {
		if ( self::POST_TYPE !== $post->post_type ) {
			return;
		}

		as_unschedule_all_actions( 'ambh_end_fundraiser', array( $post_id ), 'ambassadorhub' );

		// Delete the fundraiser's featured image attachment when the post is permanently deleted.
		$thumbnail_id = (int) get_post_thumbnail_id( $post_id );
		if ( $thumbnail_id > 0 ) {
			wp_delete_attachment( $thumbnail_id, true );
		}
	}

	/**
	 * Inject responsive CSS for the single-fundraiser template.
	 *
	 * Uses display:contents on the left column to make its children direct grid
	 * items, then reorders via CSS order: photo → team-info → sidebar (form) → content.
	 *
	 * @return void
	 */
	public static function enqueue_template_styles(): void {
		if ( ! is_singular( self::POST_TYPE ) ) {
			return;
		}

		wp_add_inline_style(
			'ambh-theme-vars',
			'
			
			.ambh-sticky {
				position: sticky;
				top: 100px;
				align-self: start;
				/* Ensure the sticky element can grow taller than the viewport if needed, and that its container can shrink below its content height to allow that. */
				overflow: visible;
			}
		
			@media (max-width: 1024px) {

				.ambh-sticky {
					position: static;
					max-height: none;
				}

				/* Force a single-column grid — display:grid must be explicit or WP collapses it to block. */
				.ambh-fundraiser-layout {
					display: grid !important;
					grid-template-columns: minmax(0, 1fr) !important; /* mobile: 1 col */

					> * {
						grid-column: span 1;
					}
				}

				/* Reset the sidebar columnSpan/rowSpan inline styles set by the block editor. */
				.ambh-fundraiser-sidebar {
					grid-column: 1 / -1 !important;
					grid-row: auto !important;
					min-width: 0;
				}

				/* Unwrap the left column — display:contents makes its children direct grid items
				 * so ambh-order-N can reorder them alongside the sidebar. */
				.ambh-fundraiser-content {
					display: contents;
				}

				/* Left-column children become full-width grid items; appear last by default. */
				.ambh-fundraiser-content > * {
					order: 10;
					grid-column: 1 / -1 !important;
					width: 100% !important;
					max-width: 100% !important;
					min-width: 0 !important;
					margin-left: 0 !important;
					margin-right: 0 !important;
					box-sizing: border-box;
				}

				/* Force flex containers to wrap. */
				.ambh-fundraiser-content .is-layout-flex,
				.ambh-fundraiser-sidebar .is-layout-flex {
					flex-wrap: wrap !important;
				}

				/* Allow every descendant to shrink below its content size. */
				.ambh-fundraiser-content > * *,
				.ambh-fundraiser-sidebar * {
					min-width: 0;
					box-sizing: border-box;
				}

				/* Mobile order utilities — set via "Additional CSS class(es)" in the block editor.
				 * Apply to direct grid items: children of .ambh-fundraiser-content, or .ambh-fundraiser-sidebar itself. */
				.ambh-order-1 { order: 1 !important; }
				.ambh-order-2 { order: 2 !important; }
				.ambh-order-3 { order: 3 !important; }
				.ambh-order-4 { order: 4 !important; }
				.ambh-order-5 { order: 5 !important; }
				.ambh-order-6 { order: 6 !important; }
				.ambh-order-7 { order: 7 !important; }
				.ambh-order-8 { order: 8 !important; }
				.ambh-order-9 { order: 9 !important; }
			}
		'
		);
	}

	/**
	 * Register all Gutenberg blocks for this module.
	 *
	 * @return void
	 */
	private static function register_blocks(): void {
		// Fundraiser-scoped blocks.
		Blocks\CampaignInfo\Block::init();
		Blocks\TeamInfo\Block::init();
		Blocks\UserInfo\Block::init();
		Blocks\Stats\Block::init();
		Blocks\TotalRaised\Block::init();
		Blocks\DonationCount\Block::init();
		Blocks\FundraisingProgress\Block::init();
		Blocks\MiniProgress\Block::init();
		Blocks\CircularProgress\Block::init();
		Blocks\TeamCount\Block::init();
		Blocks\DaysRemaining\Block::init();
		Blocks\DonationsList\Block::init();
		Blocks\DonorsList\Block::init();
		Blocks\DonationForm\Block::init();
		Blocks\DonationConfirmation\Block::init();
		Blocks\CreateFundraiserForm\Block::init();
		Blocks\CreateFundraiserButton\Block::init();
		Blocks\CreateTeamButton\Block::init();
		Blocks\TopFundraisers\Block::init();
		Blocks\QRCode\Block::init();
		Blocks\Ambassadors\Block::init();
		Blocks\FundraiserGrid\Block::init();
		Blocks\ShareButtons\Block::init();
		Blocks\ActivityFeed\Block::init();
		Blocks\DonorWall\Block::init();
		Blocks\FundraiserBreadcrumb\Block::init();
		Blocks\JoinTeam\Block::init();

		// Blocks migrated from the Campaigns module.
		\AmbassadorHub\Campaigns\Blocks\Fundraisers\Block::init();
		\AmbassadorHub\Campaigns\Blocks\CampaignGrid\Block::init();
		\AmbassadorHub\Campaigns\Blocks\CampaignCountdown\Block::init();
	}

	// =========================================================================
	// Team member invitation
	// =========================================================================

	/**
	 * Register the join-team page with the PageInstaller via filter.
	 *
	 * @param array $pages Existing page definitions.
	 * @return array
	 */
	public static function register_join_team_page( array $pages ): array {
		$pages['join-team'] = array(
			'label'           => __( 'Join team', 'ambassadorhub' ),
			'title'           => __( 'Join team', 'ambassadorhub' ),
			'slug'            => 'join-team',
			'option_key'      => 'ambh_join_team_page_id',
			'content'         => '<!-- wp:ambassadorhub/join-team /-->',
			'template'        => '',
			'expected_blocks' => array( 'ambassadorhub/join-team' ),
		);
		return $pages;
	}

	/**
	 * Handle the "Send invitation" form submission.
	 *
	 * Validates the request, applies rate limiting and duplicate checks,
	 * generates a token, and dispatches the invitation email.
	 *
	 * @return void
	 */
	public static function handle_send_invite(): void {
		// Must be logged in.
		if ( ! is_user_logged_in() ) {
			wp_safe_redirect( home_url( '/' ) );
			exit;
		}

		$fundraiser_id = isset( $_POST['fundraiser_id'] ) ? (int) $_POST['fundraiser_id'] : 0;
		$redirect_url  = isset( $_POST['redirect_url'] ) ? esc_url_raw( wp_unslash( $_POST['redirect_url'] ) ) : '';

		// Build a safe fallback redirect that includes the fundraiser ID so the
		// right invite form can show feedback.
		$account_url = AccountsSettings::get_account_page_url();
		$account_url = $account_url ? $account_url : home_url( '/' );
		$error_base  = add_query_arg( 'ambh_if_id', $fundraiser_id, $redirect_url ? $redirect_url : $account_url );
		$success_url = add_query_arg(
			array(
				'ambh_invite_sent' => '1',
				'ambh_if_id'       => $fundraiser_id,
			),
			$redirect_url ? $redirect_url : $account_url
		);

		// Verify nonce.
		$nonce = isset( $_POST['_ambh_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_ambh_nonce'] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, 'ambh_invite_member_' . $fundraiser_id ) ) {
			wp_safe_redirect( add_query_arg( 'ambh_invite_error', 'invalid', $error_base ) );
			exit;
		}

		// Verify fundraiser exists and is a team owned by the current user (or admin).
		$fundraiser = get_post( $fundraiser_id );
		if (
			! $fundraiser
			|| self::POST_TYPE !== $fundraiser->post_type
			|| ! FundraisersHelpers::fundraiser_is_team( $fundraiser_id )
		) {
			wp_safe_redirect( add_query_arg( 'ambh_invite_error', 'invalid', $error_base ) );
			exit;
		}

		$current_user_id = get_current_user_id();
		if ( (int) $fundraiser->post_author !== $current_user_id && ! current_user_can( 'manage_options' ) ) {
			wp_safe_redirect( add_query_arg( 'ambh_invite_error', 'invalid', $error_base ) );
			exit;
		}

		// Read and sanitize form fields.
		$first_name = isset( $_POST['first_name'] ) ? sanitize_text_field( wp_unslash( $_POST['first_name'] ) ) : '';
		$email      = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';

		if ( empty( $email ) || ! is_email( $email ) ) {
			wp_safe_redirect( add_query_arg( 'ambh_invite_error', 'invalid', $error_base ) );
			exit;
		}

		// Rate limit: max 10 invitations per team per hour.
		if ( ! \AmbassadorHub\Fundraisers\Invite\InviteManager::check_rate_limit( $fundraiser_id ) ) {
			wp_safe_redirect( add_query_arg( 'ambh_invite_error', 'rate_limit', $error_base ) );
			exit;
		}

		// Additional per-account limit (not just per-team) so an account can't get
		// around the above by repeatedly creating new teams. See SpamShield.
		if ( ! \AmbassadorHub\Spam\SpamShield::throttle( 'invite_acct_' . $current_user_id, 20, DAY_IN_SECONDS ) ) {
			wp_safe_redirect( add_query_arg( 'ambh_invite_error', 'rate_limit', $error_base ) );
			exit;
		}

		// Duplicate block: same email cannot be invited to the same team within 7 days.
		if ( ! \AmbassadorHub\Fundraisers\Invite\InviteManager::check_duplicate( $email, $fundraiser_id ) ) {
			wp_safe_redirect( add_query_arg( 'ambh_invite_error', 'duplicate', $error_base ) );
			exit;
		}

		// Determine whether this email already has a WP account.
		$type = email_exists( $email ) ? 'join' : 'new';

		// Generate token and persist transients.
		$token = \AmbassadorHub\Fundraisers\Invite\InviteManager::generate_token(
			$fundraiser_id,
			$email,
			$first_name,
			$current_user_id,
			$type
		);

		// Build the join-team page URL with the token.
		$join_page_id  = (int) get_option( 'ambh_join_team_page_id', 0 );
		$join_page_url = get_permalink( $join_page_id );
		$join_page_url = $join_page_url ? $join_page_url : home_url( '/join-team/' );
		$join_url      = $join_page_id
			? add_query_arg( 'invite_token', $token, $join_page_url )
			: add_query_arg( 'invite_token', $token, home_url( '/join-team/' ) );

		// Resolve sender display name.
		$sender      = get_userdata( $current_user_id );
		$sender_name = $sender ? ( $sender->first_name ? $sender->first_name : $sender->display_name ) : get_bloginfo( 'name' );

		// Send the invitation email.
		\AmbassadorHub\Mail\TransactionalMailer::send_invite_email(
			$email,
			array(
				'first_name'  => $first_name,
				'sender_name' => $sender_name,
				'team_name'   => get_the_title( $fundraiser_id ),
				'invite_url'  => $join_url,
				'type'        => $type,
			)
		);

		wp_safe_redirect( $success_url );
		exit;
	}

	/**
	 * Redirect unauthenticated POST attempts to the login page.
	 *
	 * @return void
	 */
	public static function handle_invite_not_logged_in(): void {
		$login_url = AccountsSettings::get_login_page_url();
		$login_url = $login_url ? $login_url : home_url( '/' );
		wp_safe_redirect( $login_url );
		exit;
	}
}
