<?php
/**
 * Fundraisers Module Automations
 *
 * Registers automation triggers, conditions, and event handlers
 * for fundraiser lifecycle events.
 *
 * @package AmbassadorHub\Fundraisers
 */

namespace AmbassadorHub\Fundraisers;

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

use AmbassadorHub\Automations\AbstractAutomations;
use AmbassadorHub\Automations\ScheduleEngine;

use function add_action;
use function add_filter;
use function esc_html__;
use function get_post;
use function get_post_meta;
use function get_permalink;
use function get_userdata;
use function update_post_meta;
use function delete_post_meta;
use function wp_is_post_revision;

/**
 * Consolidates automation registration and event handling for the Fundraisers module.
 *
 * Extends AbstractAutomations to follow the same pattern as Accounts/Automations.
 *
 * Registers:
 * - Trigger group: fundraisers
 * - Triggers: fundraiser_published, fundraiser_updated,
 *             fundraiser_goal_reached, fundraiser_deactivated, fundraiser_pending_verification
 * - Conditions: fundraiser_total_raised, fundraiser_donation_count,
 *               fundraiser_goal_progress_pct
 * - Metric providers for condition evaluation via FundraiserCache.
 * - Event handlers for WP post status transitions and save lifecycle.
 *
 * Goal-reached behaviour:
 *   The trigger fires at most once per fundraiser per target-amount milestone.
 *   A post-meta flag (_ambf_goal_reached) prevents repeated firing when multiple
 *   donations arrive while the goal is already met. The flag is cleared whenever
 *   the _ambh_target_amount meta is updated so the trigger can fire again for a new,
 *   higher goal.
 */
final class FundraisersAutomations extends AbstractAutomations {

	/**
	 * Post-meta key that marks a fundraiser whose goal has already triggered.
	 */
	private const META_GOAL_REACHED = '_ambh_goal_reached';

	/**
	 * Post-meta key that stores the target_amount at the time goal was last fired,
	 * so we can detect when the admin raises the target and re-arm the trigger.
	 */
	private const META_GOAL_REACHED_TARGET = '_ambh_goal_reached_target';

	/**
	 * Post-meta key storing a JSON array of milestone percentages (10,25,50,75) already fired.
	 * Reset when the fundraiser's target amount changes.
	 */
	private const META_DONATION_MILESTONES = '_ambh_donation_milestones';

	/**
	 * Post-meta key that marks whether the first-donation trigger has already fired.
	 */
	private const META_FIRST_DONATION_FIRED = '_ambh_first_donation_fired';

	// ===========================
	// Bootstrap
	// ===========================

	/**
	 * Register module-specific event handlers.
	 *
	 * Called by AbstractAutomations::init() during initialization.
	 *
	 * @return void
	 */
	protected static function register_event_handlers(): void {
		// Status transitions (published / deactivated).
		add_action( 'transition_post_status', array( self::class, 'on_post_status_transition' ), 10, 3 );

		// Saved / updated (content, meta changes).
		add_action( 'save_post_ambh_fundraiser', array( self::class, 'on_fundraiser_saved' ), 20, 3 );

		// Type-specific creation triggers (team/individual): fired after REST API sets all meta.
		add_action( 'ambh_fundraiser_rest_saved', array( self::class, 'on_fundraiser_rest_saved' ), 10, 3 );

		// Goal-reached and milestone checks — runs after FundraiserCache is refreshed.
		add_action( 'ambh_fundraiser_cache_updated', array( self::class, 'on_fundraiser_cache_updated' ), 10, 2 );

		// Webhook payload — build the base fundraiser identity + goal block (priority 10, before DonationsAutomations stats at 20).
		add_filter( 'ambh_get_webhook_action_payload_fundraiser', array( self::class, 'get_fundraiser_webhook_payload' ), 10, 3 );

		// Email action payload — add goal-specific template vars (priority 20, after DonationsAutomations at 10).
		add_filter( 'ambh_get_email_action_payload_fundraiser', array( self::class, 'get_fundraiser_email_payload' ), 20, 3 );

		// Email recipient roles — register selectable roles for the fundraiser entity.
		add_filter( 'ambh_email_recipient_roles', array( self::class, 'register_email_recipient_roles' ) );

		// Template tokens for the variable picker in the automation UI.
		add_filter( 'ambh_automation_template_tokens', array( self::class, 'register_template_tokens' ), 10, 2 );

		// Role-specific recipient override: admin sends to site admin email.
		add_filter( 'ambh_get_email_action_payload_fundraiser_admin', array( self::class, 'get_fundraiser_email_payload_admin' ), 10, 3 );

		// Fan-out recipient handlers.
		add_filter( 'ambh_get_email_action_payload_fundraiser_group_owner', array( self::class, 'get_fundraiser_email_payload_group_owner' ), 10, 3 );
		add_filter( 'ambh_get_email_action_payload_fundraiser_group_members', array( self::class, 'get_fundraiser_email_payload_group_members' ), 10, 3 );
		add_filter( 'ambh_get_email_action_payload_fundraiser_all_individuals', array( self::class, 'get_fundraiser_email_payload_all_individuals' ), 10, 3 );
		add_filter( 'ambh_get_email_action_payload_fundraiser_all_group_captains', array( self::class, 'get_fundraiser_email_payload_all_group_captains' ), 10, 3 );
	}

	// ===========================
	// Trigger Registration
	// ===========================

	/**
	 * Register the fundraisers trigger group.
	 *
	 * @param array $groups Existing trigger groups.
	 *
	 * @return array Modified groups.
	 */
	public static function register_trigger_groups( array $groups ): array {
		$groups['fundraisers'] = array(
			'label' => esc_html__( 'Fundraiser events', 'ambassadorhub' ),
			'icon'  => 'dashicons-heart',
		);

		return $groups;
	}

	/**
	 * Register fundraiser lifecycle triggers.
	 *
	 * @param array $triggers Existing triggers.
	 *
	 * @return array Modified triggers.
	 */
	public static function register_triggers( array $triggers ): array {

		$triggers['fundraiser_created'] = array(
			'label'       => esc_html__( 'Fundraiser created', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered when a new fundraiser is created for the first time, regardless of its initial status.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'created',
			'icon'        => 'dashicons-plus-alt',
		);

		$triggers['fundraiser_published'] = array(
			'label'       => esc_html__( 'Fundraiser published', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered when a fundraiser transitions to published status for the first time or re-publishes after being deactivated.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'published',
			'icon'        => 'dashicons-visibility',
		);

		$triggers['fundraiser_updated'] = array(
			'label'       => esc_html__( 'Fundraiser updated', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered when an existing published fundraiser is saved with changed data (title, description, target, etc.).', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'updated',
			'icon'        => 'dashicons-edit',
		);

		$triggers['fundraiser_goal_reached'] = array(
			'label'       => esc_html__( 'Fundraiser goal reached', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered (once per target milestone) when a fundraiser\'s total raised amount first reaches or exceeds its target amount.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'goal_reached',
			'icon'        => 'dashicons-awards',
		);

		$triggers['fundraiser_deactivated'] = array(
			'label'       => esc_html__( 'Fundraiser deactivated', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered when a published fundraiser is moved to draft, private, or trash.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'deactivated',
			'icon'        => 'dashicons-hidden',
		);

		$triggers['fundraiser_pending_verification'] = array(
			'label'       => esc_html__( 'Fundraiser pending verification', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered when a fundraiser is created by an ambassador whose email address is not yet verified.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'pending_verification',
			'icon'        => 'dashicons-clock',
		);

		$triggers['fundraiser_ended'] = array(
			'label'       => esc_html__( 'Fundraiser ended', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered when a fundraiser reaches its end date and transitions to the ended status.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'ended',
			'icon'        => 'dashicons-flag',
		);

		$triggers['root_fundraiser_updated'] = array(
			'label'       => esc_html__( 'Root fundraiser updated', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered when a top-level (root/campaign) fundraiser is saved with changes. Use to notify everyone underneath when the campaign goal or content changes.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'root_updated',
			'icon'        => 'dashicons-admin-home',
		);

		$triggers['fundraiser_root_created'] = array(
			'label'       => esc_html__( 'Root fundraiser created', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered when a new top-level (root/campaign) fundraiser is created, regardless of its initial status.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'root_created',
			'icon'        => 'dashicons-admin-home',
		);

		$triggers['team_created'] = array(
			'label'       => esc_html__( 'Group/team created', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered when a new group or team fundraiser is created under a root fundraiser. Entity is the team fundraiser.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'team_created',
			'icon'        => 'dashicons-groups',
		);

		$triggers['individual_fundraiser_created'] = array(
			'label'       => esc_html__( 'Individual fundraiser created', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered when a new personal (individual) fundraiser is created by an ambassador. Entity is the individual fundraiser.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'individual_created',
			'icon'        => 'dashicons-admin-users',
		);

		$triggers['fundraiser_first_donation'] = array(
			'label'       => esc_html__( 'First donation received', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered exactly once when a fundraiser receives its very first completed donation.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'first_donation',
			'icon'        => 'dashicons-star-filled',
		);

		$triggers['fundraiser_milestone_10pct'] = array(
			'label'       => esc_html__( 'Fundraiser reached 10% of goal', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered once when a fundraiser\'s total raised first reaches 10% of its target amount. Resets if the target amount is raised.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'milestone_10pct',
			'icon'        => 'dashicons-chart-bar',
		);

		$triggers['fundraiser_milestone_25pct'] = array(
			'label'       => esc_html__( 'Fundraiser reached 25% of goal', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered once when a fundraiser\'s total raised first reaches 25% of its target amount. Resets if the target amount is raised.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'milestone_25pct',
			'icon'        => 'dashicons-chart-bar',
		);

		$triggers['fundraiser_milestone_50pct'] = array(
			'label'       => esc_html__( 'Fundraiser reached 50% of goal', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered once when a fundraiser\'s total raised first reaches 50% of its target amount. Resets if the target amount is raised.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'milestone_50pct',
			'icon'        => 'dashicons-chart-bar',
		);

		$triggers['fundraiser_milestone_75pct'] = array(
			'label'       => esc_html__( 'Fundraiser reached 75% of goal', 'ambassadorhub' ),
			'description' => esc_html__( 'Triggered once when a fundraiser\'s total raised first reaches 75% of its target amount. Resets if the target amount is raised.', 'ambassadorhub' ),
			'group'       => 'fundraisers',
			'entity_type' => 'fundraiser',
			'event'       => 'milestone_75pct',
			'icon'        => 'dashicons-chart-bar',
		);

		return $triggers;
	}

	// ===========================
	// Condition Registration
	// ===========================

	/**
	 * Register fundraiser-specific conditions.
	 *
	 * @param array $conditions Existing conditions.
	 * @param array $context    Context data.
	 *
	 * @return array Modified conditions.
	 */
	public static function register_conditions( array $conditions, array $context ): array {

		$conditions['fundraiser_total_raised'] = array(
			'label'                  => esc_html__( 'Total amount raised', 'ambassadorhub' ),
			'description'            => esc_html__( 'Compare the total amount raised by this fundraiser (completed donations only).', 'ambassadorhub' ),
			'supported_entity_types' => array( 'fundraiser' ),
			'operator_type'          => 'compare',
			'operators'              => array( '<', '>', '==', '!=', '<=', '>=' ),
			'data_source'            => 'metric',
			'metric_key'             => 'fundraiser_total_raised',
			'value_type'             => 'number',
			'value_options'          => null,
		);

		$conditions['fundraiser_donation_count'] = array(
			'label'                  => esc_html__( 'Number of completed donations', 'ambassadorhub' ),
			'description'            => esc_html__( 'Compare the number of completed donations received by this fundraiser.', 'ambassadorhub' ),
			'supported_entity_types' => array( 'fundraiser' ),
			'operator_type'          => 'compare',
			'operators'              => array( '<', '>', '==', '!=', '<=', '>=' ),
			'data_source'            => 'metric',
			'metric_key'             => 'fundraiser_donation_count',
			'value_type'             => 'number',
			'value_options'          => null,
		);

		$conditions['fundraiser_goal_progress_pct'] = array(
			'label'                  => esc_html__( 'Goal progress (%)', 'ambassadorhub' ),
			'description'            => esc_html__( 'Compare what percentage of the fundraiser\'s target amount has been raised (0–100+).', 'ambassadorhub' ),
			'supported_entity_types' => array( 'fundraiser' ),
			'operator_type'          => 'compare',
			'operators'              => array( '<', '>', '==', '!=', '<=', '>=' ),
			'data_source'            => 'metric',
			'metric_key'             => 'fundraiser_goal_progress_pct',
			'value_type'             => 'number',
			'value_options'          => null,
		);

		return $conditions;
	}

	// ===========================
	// Metric Values
	// ===========================

	/**
	 * Provide metric values for condition evaluation (fundraiser entity only).
	 *
	 * @param mixed  $value              Existing value (pass-through if not handled).
	 * @param string $metric_key         Metric identifier.
	 * @param int    $target_id          Fundraiser post ID.
	 * @param string $target_entity_type Entity type.
	 *
	 * @return mixed Resolved metric value or original $value.
	 */
	public static function get_metric_value( mixed $value, string $metric_key, int $target_id, string $target_entity_type ): mixed {
		if ( 'fundraiser' !== $target_entity_type ) {
			return $value;
		}

		$cache = FundraiserCache::get_or_update_cache( $target_id );

		switch ( $metric_key ) {
			case 'fundraiser_total_raised':
				return (float) ( $cache['total_amount'] ?? 0.0 );

			case 'fundraiser_donation_count':
				return (int) ( $cache['completed_count'] ?? 0 );

			case 'fundraiser_goal_progress_pct':
				$target_amount = (float) get_post_meta( $target_id, '_ambh_target_amount', true );
				if ( $target_amount <= 0 ) {
					return 0.0;
				}
				$total_raised = (float) ( $cache['total_amount'] ?? 0.0 );
				return round( ( $total_raised / $target_amount ) * 100, 2 );
		}

		return $value;
	}

	// ===========================
	// Event Handlers
	// ===========================

	/**
	 * Handle fundraiser post status transitions.
	 *
	 * Fires fundraiser_published when status changes to 'publish'.
	 * Fires fundraiser_deactivated when a published fundraiser moves to draft/private/trash.
	 *
	 * @param string   $new_status New post status.
	 * @param string   $old_status Old post status.
	 * @param \WP_Post $post       Post object.
	 *
	 * @return void
	 */
	public static function on_post_status_transition( string $new_status, string $old_status, \WP_Post $post ): void {
		if ( FundraisersModule::POST_TYPE !== $post->post_type || $new_status === $old_status ) {
			return;
		}

		$context = array(
			'status_from' => $old_status,
			'status_to'   => $new_status,
			'source'      => 'status_transition',
		);

		// Published (going live).
		if ( 'publish' === $new_status && 'publish' !== $old_status ) {
			ScheduleEngine::schedule_rules_for_trigger( 'fundraiser_published', $post->ID, $context );

			/**
			 * Fires when a fundraiser is published (goes live).
			 *
			 * @since 0.4.0
			 *
			 * @param int $post_id Fundraiser post ID.
			 */
			do_action( 'ambh_fundraiser_published', $post->ID );
		}

		// Deactivated (taken offline from a live state).
		if ( in_array( $new_status, array( 'draft', 'private', 'trash' ), true ) && 'publish' === $old_status ) {
			ScheduleEngine::schedule_rules_for_trigger( 'fundraiser_deactivated', $post->ID, $context );
		}

		// Pending verification (created by an ambassador who has not yet verified their email).
		if ( 'pending_verification' === $new_status && 'pending_verification' !== $old_status ) {
			ScheduleEngine::schedule_rules_for_trigger( 'fundraiser_pending_verification', $post->ID, $context );
		}

		// Ended (fundraiser reached its own end date).
		if ( 'ended' === $new_status && 'ended' !== $old_status ) {
			ScheduleEngine::schedule_rules_for_trigger( 'fundraiser_ended', $post->ID, $context );

			/**
			 * Fires when a fundraiser transitions to the ended status.
			 *
			 * @since 0.4.0
			 *
			 * @param int $post_id Fundraiser post ID.
			 */
			do_action( 'ambh_fundraiser_ended', $post->ID );
		}
	}

	/**
	 * Handle published fundraiser saves.
	 *
	 * Fires fundraiser_updated when an existing published fundraiser is saved.
	 * Also resets the goal-reached flag when the target amount changes.
	 *
	 * @param int      $post_id Post ID.
	 * @param \WP_Post $post    Post object.
	 * @param bool     $update  Whether this is an update (vs first insert).
	 *
	 * @return void
	 */
	public static function on_fundraiser_saved( int $post_id, \WP_Post $post, bool $update ): void {
		// Skip autosaves and revisions.
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}
		if ( wp_is_post_revision( $post_id ) ) {
			return;
		}

		// Fire fundraiser_created (catch-all) and fundraiser_root_created on first insert.
		if ( ! $update ) {
			$creation_context = array(
				'status' => $post->post_status,
				'source' => 'fundraiser_created',
			);
			ScheduleEngine::schedule_rules_for_trigger( 'fundraiser_created', $post_id, $creation_context );

			// post_parent is part of core post data and is always available at save time.
			if ( 0 === (int) $post->post_parent ) {
				ScheduleEngine::schedule_rules_for_trigger( 'fundraiser_root_created', $post_id, $creation_context );
			}
		}

		// Only fire fundraiser_updated (and root variant) for existing, published fundraisers.
		if ( $update && 'publish' === $post->post_status ) {
			ScheduleEngine::schedule_rules_for_trigger(
				'fundraiser_updated',
				$post_id,
				array( 'source' => 'fundraiser_saved' )
			);

			if ( 0 === (int) $post->post_parent ) {
				ScheduleEngine::schedule_rules_for_trigger(
					'root_fundraiser_updated',
					$post_id,
					array( 'source' => 'fundraiser_saved' )
				);
			}

			/**
			 * Fires when a published fundraiser is updated.
			 *
			 * @param int $post_id Fundraiser post ID.
			 */
			do_action( 'ambh_fundraiser_updated', $post_id );
		}

		// Re-arm the goal-reached trigger when the target amount is changed.
		// We compare against the stored target at last fire time.
		$new_target = (float) get_post_meta( $post_id, '_ambh_target_amount', true );
		$fired_at   = (float) get_post_meta( $post_id, self::META_GOAL_REACHED_TARGET, true );

		if ( $new_target > 0 && (int) $new_target !== (int) $fired_at ) {
			delete_post_meta( $post_id, self::META_GOAL_REACHED );
			delete_post_meta( $post_id, self::META_DONATION_MILESTONES );
			update_post_meta( $post_id, self::META_GOAL_REACHED_TARGET, $new_target );
		}
	}

	/**
	 * Fire type-specific creation triggers after REST API has saved all fundraiser meta.
	 *
	 * Root fundraiser creation is already fired from on_fundraiser_saved() because
	 * post_parent is always available at save_post time. This handler covers team and
	 * individual fundraisers, for which META_IS_TEAM is set after wp_insert_post().
	 *
	 * @param int              $fundraiser_id Fundraiser post ID.
	 * @param \WP_REST_Request $request       REST request.
	 * @param string           $mode          'create' or 'update'.
	 *
	 * @return void
	 */
	public static function on_fundraiser_rest_saved( int $fundraiser_id, \WP_REST_Request $request, string $mode ): void {
		if ( 'create' !== $mode ) {
			return;
		}

		$post = get_post( $fundraiser_id );
		if ( ! $post ) {
			return;
		}

		// Root is already handled by on_fundraiser_saved().
		if ( 0 === (int) $post->post_parent ) {
			return;
		}

		$is_team = get_post_meta( $fundraiser_id, FundraisersModule::META_IS_TEAM, true ) === '1';
		$context = array(
			'parent_id'   => (int) $post->post_parent,
			'is_team'     => $is_team,
			'post_status' => $post->post_status,
			'source'      => 'rest_api',
		);

		$trigger = $is_team ? 'team_created' : 'individual_fundraiser_created';
		ScheduleEngine::schedule_rules_for_trigger( $trigger, $fundraiser_id, $context );
	}

	/**
	 * Check donation milestones and goal-reached after a cache refresh.
	 *
	 * Delegates to focused private helpers so each concern is isolated.
	 *
	 * @param int   $fundraiser_id Fundraiser post ID.
	 * @param array $cache_data    Updated FundraiserCache data.
	 *
	 * @return void
	 */
	public static function on_fundraiser_cache_updated( int $fundraiser_id, array $cache_data ): void {
		self::maybe_fire_first_donation( $fundraiser_id, $cache_data );

		$target_amount = (float) get_post_meta( $fundraiser_id, '_ambh_target_amount', true );
		if ( $target_amount > 0 ) {
			$total_raised = (float) ( $cache_data['total_amount'] ?? 0.0 );
			self::maybe_fire_percentage_milestones( $fundraiser_id, $target_amount, $total_raised );
			self::maybe_fire_goal_reached( $fundraiser_id, $target_amount, $total_raised );
		}
	}

	/**
	 * Fire fundraiser_first_donation exactly once.
	 *
	 * @param int   $fundraiser_id Fundraiser post ID.
	 * @param array $cache_data    Updated cache data.
	 *
	 * @return void
	 */
	private static function maybe_fire_first_donation( int $fundraiser_id, array $cache_data ): void {
		if ( (int) ( $cache_data['completed_count'] ?? 0 ) < 1 ) {
			return;
		}

		if ( get_post_meta( $fundraiser_id, self::META_FIRST_DONATION_FIRED, true ) ) {
			return;
		}

		update_post_meta( $fundraiser_id, self::META_FIRST_DONATION_FIRED, '1' );

		ScheduleEngine::schedule_rules_for_trigger(
			'fundraiser_first_donation',
			$fundraiser_id,
			array(
				'total_amount'    => $cache_data['total_amount'] ?? 0,
				'completed_count' => $cache_data['completed_count'] ?? 0,
				'source'          => 'first_donation',
			)
		);
	}

	/**
	 * Fire percentage-milestone triggers (10%, 25%, 50%, 75%) at most once each.
	 *
	 * Milestones reset (flag cleared) if donations are refunded and progress drops
	 * below a threshold, so the trigger can re-fire if the milestone is re-crossed.
	 *
	 * @param int   $fundraiser_id Fundraiser post ID.
	 * @param float $target_amount Fundraiser target amount (> 0).
	 * @param float $total_raised  Current total raised.
	 *
	 * @return void
	 */
	private static function maybe_fire_percentage_milestones( int $fundraiser_id, float $target_amount, float $total_raised ): void {
		$progress_pct = ( $total_raised / $target_amount ) * 100;

		$pct_triggers = array(
			10 => 'fundraiser_milestone_10pct',
			25 => 'fundraiser_milestone_25pct',
			50 => 'fundraiser_milestone_50pct',
			75 => 'fundraiser_milestone_75pct',
		);

		$raw          = (string) get_post_meta( $fundraiser_id, self::META_DONATION_MILESTONES, true );
		$hit          = json_decode( $raw, true );
		$hit          = is_array( $hit ) ? $hit : array();
		$changed      = false;
		$base_context = array(
			'target_amount' => $target_amount,
			'total_raised'  => $total_raised,
			'progress_pct'  => round( $progress_pct, 1 ),
		);

		foreach ( $pct_triggers as $pct => $trigger_id ) {
			$already_hit = in_array( $pct, $hit, true );

			if ( $progress_pct >= $pct && ! $already_hit ) {
				$hit[]   = $pct;
				$changed = true;
				ScheduleEngine::schedule_rules_for_trigger(
					$trigger_id,
					$fundraiser_id,
					array_merge( $base_context, array( 'milestone_pct' => $pct ) )
				);
			} elseif ( $progress_pct < $pct && $already_hit ) {
				$hit     = array_values( array_diff( $hit, array( $pct ) ) );
				$changed = true;
			}
		}

		if ( $changed ) {
			update_post_meta( $fundraiser_id, self::META_DONATION_MILESTONES, wp_json_encode( $hit ) );
		}
	}

	/**
	 * Fire fundraiser_goal_reached (100%) at most once per target amount.
	 *
	 * Clears the flag if donations drop below the target so it can re-fire.
	 *
	 * @param int   $fundraiser_id Fundraiser post ID.
	 * @param float $target_amount Fundraiser target amount (> 0).
	 * @param float $total_raised  Current total raised.
	 *
	 * @return void
	 */
	private static function maybe_fire_goal_reached( int $fundraiser_id, float $target_amount, float $total_raised ): void {
		if ( $total_raised < $target_amount ) {
			if ( get_post_meta( $fundraiser_id, self::META_GOAL_REACHED, true ) ) {
				delete_post_meta( $fundraiser_id, self::META_GOAL_REACHED );
			}
			return;
		}

		if ( get_post_meta( $fundraiser_id, self::META_GOAL_REACHED, true ) ) {
			return;
		}

		update_post_meta( $fundraiser_id, self::META_GOAL_REACHED, '1' );
		update_post_meta( $fundraiser_id, self::META_GOAL_REACHED_TARGET, $target_amount );

		ScheduleEngine::schedule_rules_for_trigger(
			'fundraiser_goal_reached',
			$fundraiser_id,
			array(
				'target_amount' => $target_amount,
				'total_raised'  => $total_raised,
				'source'        => 'goal_reached',
			)
		);
	}

	// ===========================
	// Webhook & Email Payloads
	// ===========================

	/**
	 * Register selectable email recipient roles for the fundraiser entity type.
	 *
	 * @param array $roles Existing roles map.
	 *
	 * @return array
	 */
	public static function register_email_recipient_roles( array $roles ): array {
		$roles['fundraiser'] = array(
			'owner'              => esc_html__( 'Fundraiser owner', 'ambassadorhub' ),
			'group_owner'        => esc_html__( 'Parent group/team owner', 'ambassadorhub' ),
			'group_members'      => esc_html__( 'All members of the same group (fan-out)', 'ambassadorhub' ),
			'all_individuals'    => esc_html__( 'All individual fundraisers below (fan-out)', 'ambassadorhub' ),
			'all_group_captains' => esc_html__( 'All group/team captains below (fan-out)', 'ambassadorhub' ),
			'all_donors'         => esc_html__( 'All donors to this fundraiser (fan-out)', 'ambassadorhub' ),
			'admin'              => esc_html__( 'Site administrator', 'ambassadorhub' ),
		);
		return $roles;
	}

	/**
	 * Override the email recipient to the site administrator.
	 *
	 * Template vars are already filled by get_fundraiser_email_payload(); this
	 * method only overwrites the `to` address.
	 *
	 * @param array $payload   Existing payload.
	 * @param int   $target_id Fundraiser post ID.
	 * @param int   $_action_id Action instance ID.
	 *
	 * @return array
	 */
	public static function get_fundraiser_email_payload_admin( array $payload, int $target_id, int $_action_id ): array {
		$payload['to'] = get_bloginfo( 'admin_email' );
		return $payload;
	}

	/**
	 * Build the fundraiser webhook payload: identity block + goal progress.
	 *
	 * Runs at priority 10, before DonationsAutomations (priority 20) appends
	 * the donation aggregate 'stats' block.
	 *
	 * Adds to the payload:
	 *   'fundraiser' => [ ID, title, status, url, root_id, target_amount, owner_id ]
	 *   'goal'       => [ target_amount, progress_pct, goal_reached ]
	 *
	 * @param array $payload   Existing payload.
	 * @param int   $target_id Fundraiser post ID.
	 * @param int   $action_id Action instance ID.
	 *
	 * @return array
	 */
	public static function get_fundraiser_webhook_payload( array $payload, int $target_id, int $_action_id ): array {
		if ( ! $target_id ) {
			return $payload;
		}

		$post = get_post( $target_id );
		if ( ! $post || FundraisersModule::POST_TYPE !== $post->post_type ) {
			return $payload;
		}

		$target_amount = (float) get_post_meta( $target_id, '_ambh_target_amount', true );

		$payload['fundraiser'] = array(
			'ID'       => $post->ID,
			'title'    => $post->post_title,
			'status'   => $post->post_status,
			'url'      => get_permalink( $post->ID ),
			'type'     => FundraisersHelpers::fundraiser_is_team( $post->ID ) ? 'team' : 'individual',
			'root_id'  => FundraisersHelpers::get_root_fundraiser_id( $post->ID ),
			'owner_id' => (int) $post->post_author,
		);

		$total_raised = (float) ( $payload['stats']['total_raised']
			?? FundraiserCache::get_or_update_cache( $target_id )['total_amount']
			?? 0.0
		);

		$progress_pct = 0.0;
		if ( $target_amount > 0 ) {
			$progress_pct = round( ( $total_raised / $target_amount ) * 100, 2 );
		}

		$payload['goal'] = array(
			'target_amount' => $target_amount,
			'progress_pct'  => $progress_pct,
			'goal_reached'  => $target_amount > 0 && $total_raised >= $target_amount,
		);

		return $payload;
	}

	/**
	 * Resolve all fundraiser IDs at or below the given one.
	 *
	 * Traverses up to two levels of children (covers MAX_DEPTH=4 with root at depth=1).
	 * Returns only IDs in 'publish' status.
	 *
	 * @param int  $fundraiser_id  Root of the sub-tree to traverse.
	 * @param bool $include_self   Whether to include $fundraiser_id itself.
	 *
	 * @return int[]
	 */
	private static function get_sub_fundraiser_ids( int $fundraiser_id, bool $include_self = false ): array {
		$ids     = $include_self ? array( $fundraiser_id ) : array();
		$level_1 = get_posts(
			array(
				'post_type'      => FundraisersModule::POST_TYPE,
				'post_parent'    => $fundraiser_id,
				'posts_per_page' => -1,
				'post_status'    => 'publish',
				'fields'         => 'ids',
			)
		);

		foreach ( $level_1 as $child_id ) {
			$ids[] = (int) $child_id;

			$level_2 = get_posts(
				array(
					'post_type'      => FundraisersModule::POST_TYPE,
					'post_parent'    => (int) $child_id,
					'posts_per_page' => -1,
					'post_status'    => 'publish',
					'fields'         => 'ids',
				)
			);

			foreach ( $level_2 as $gc_id ) {
				$ids[]   = (int) $gc_id;
				$level_3 = get_posts(
					array(
						'post_type'      => FundraisersModule::POST_TYPE,
						'post_parent'    => (int) $gc_id,
						'posts_per_page' => -1,
						'post_status'    => 'publish',
						'fields'         => 'ids',
					)
				);
				foreach ( $level_3 as $ggc_id ) {
					$ids[] = (int) $ggc_id;
				}
			}
		}

		return array_values( array_unique( $ids ) );
	}

	/**
	 * Build a to_list item from a fundraiser owner.
	 *
	 * @param int $fundraiser_id Fundraiser post ID.
	 *
	 * @return array|null {to, template_vars} or null if owner can't be resolved.
	 */
	private static function owner_list_item( int $fundraiser_id ): ?array {
		$post  = get_post( $fundraiser_id );
		$owner = $post ? get_userdata( (int) $post->post_author ) : null;
		if ( ! $owner || ! is_email( $owner->user_email ) ) {
			return null;
		}
		return array(
			'to'            => $owner->user_email,
			'template_vars' => array(
				'{{fundraiser_title}}' => $post->post_title,
				'{{fundraiser_url}}'   => (string) get_permalink( $post->ID ),
				'{{owner_first_name}}' => $owner->first_name ? $owner->first_name : $owner->display_name,
				'{{owner_last_name}}'  => $owner->last_name,
				'{{owner_email}}'      => $owner->user_email,
			),
		);
	}

	/**
	 * Override recipient to the owner of the parent group/team.
	 *
	 * If the entity is an individual with a team parent, the team owner is the
	 * "group captain". Falls through unchanged if there is no parent or the parent
	 * is a root.
	 *
	 * @param array $payload   Existing payload.
	 * @param int   $target_id Fundraiser post ID.
	 * @param int   $_action_id Action instance ID.
	 *
	 * @return array
	 */
	public static function get_fundraiser_email_payload_group_owner( array $payload, int $target_id, int $_action_id ): array {
		$post      = get_post( $target_id );
		$parent_id = $post ? (int) $post->post_parent : 0;

		if ( $parent_id <= 0 ) {
			return $payload;
		}

		$parent = get_post( $parent_id );
		if ( ! $parent ) {
			return $payload;
		}

		$owner = get_userdata( (int) $parent->post_author );
		if ( $owner ) {
			$payload['to']            = $owner->user_email;
			$payload['template_vars'] = array_merge(
				is_array( $payload['template_vars'] ?? null ) ? $payload['template_vars'] : array(),
				array(
					'{{owner_first_name}}' => $owner->first_name ? $owner->first_name : $owner->display_name,
					'{{owner_last_name}}'  => $owner->last_name,
					'{{owner_email}}'      => $owner->user_email,
				)
			);
		}

		return $payload;
	}

	/**
	 * Fan-out to all sibling individual fundraisers in the same group.
	 *
	 * Sends one email per sibling (not to the entity itself). Each item is
	 * personalised with the sibling fundraiser owner's name and fundraiser title.
	 *
	 * @param array $payload   Existing payload.
	 * @param int   $target_id Fundraiser post ID (the new individual that just joined).
	 * @param int   $_action_id Action instance ID.
	 *
	 * @return array
	 */
	public static function get_fundraiser_email_payload_group_members( array $payload, int $target_id, int $_action_id ): array {
		$post      = get_post( $target_id );
		$parent_id = $post ? (int) $post->post_parent : 0;

		if ( $parent_id <= 0 ) {
			return $payload;
		}

		$siblings = get_posts(
			array(
				'post_type'      => FundraisersModule::POST_TYPE,
				'post_parent'    => $parent_id,
				'posts_per_page' => -1,
				'post_status'    => 'publish',
				'fields'         => 'ids',
			)
		);

		$list = array();
		foreach ( $siblings as $sibling_id ) {
			$sibling_id = (int) $sibling_id;
			// Filtered here rather than via a query-level 'exclude' — this is
			// already scoped to a single fundraiser's siblings by post_parent,
			// so an in-PHP skip avoids the exclusionary-parameter performance
			// warning without a second query.
			if ( $sibling_id === $target_id ) {
				continue;
			}
			if ( FundraisersHelpers::fundraiser_is_team( $sibling_id ) ) {
				continue; // Skip sub-teams; only email individual members.
			}
			$item = self::owner_list_item( (int) $sibling_id );
			if ( $item ) {
				$list[] = $item;
			}
		}

		if ( ! empty( $list ) ) {
			$payload['to_list'] = $list;
		}

		return $payload;
	}

	/**
	 * Fan-out to all individual fundraisers below the entity.
	 *
	 * Useful for a root or group fundraiser to notify every individual beneath it
	 * (e.g. when the campaign goal or content is updated).
	 *
	 * @param array $payload   Existing payload.
	 * @param int   $target_id Fundraiser post ID (root or group).
	 * @param int   $_action_id Action instance ID.
	 *
	 * @return array
	 */
	public static function get_fundraiser_email_payload_all_individuals( array $payload, int $target_id, int $_action_id ): array {
		$sub_ids = self::get_sub_fundraiser_ids( $target_id );

		$list = array();
		foreach ( $sub_ids as $sub_id ) {
			if ( FundraisersHelpers::fundraiser_is_team( $sub_id ) ) {
				continue;
			}
			$item = self::owner_list_item( $sub_id );
			if ( $item ) {
				$list[] = $item;
			}
		}

		if ( ! empty( $list ) ) {
			$payload['to_list'] = $list;
		}

		return $payload;
	}

	/**
	 * Fan-out to all group/team captains below the entity.
	 *
	 * Targets the owner of every team-type fundraiser under the root. Useful when
	 * a campaign update should reach each group leader specifically.
	 *
	 * @param array $payload   Existing payload.
	 * @param int   $target_id Fundraiser post ID (root).
	 * @param int   $_action_id Action instance ID.
	 *
	 * @return array
	 */
	public static function get_fundraiser_email_payload_all_group_captains( array $payload, int $target_id, int $_action_id ): array {
		$sub_ids = self::get_sub_fundraiser_ids( $target_id );

		$list = array();
		foreach ( $sub_ids as $sub_id ) {
			if ( ! FundraisersHelpers::fundraiser_is_team( $sub_id ) ) {
				continue;
			}
			$item = self::owner_list_item( $sub_id );
			if ( $item ) {
				$list[] = $item;
			}
		}

		if ( ! empty( $list ) ) {
			$payload['to_list'] = $list;
		}

		return $payload;
	}

	/**
	 * Enrich the fundraiser email payload with goal-specific template variables.
	 *
	 * Runs at priority 20, after DonationsAutomations (priority 10) has already
	 * set the recipient email and base fundraiser template vars. This adds:
	 *   {{target_amount}}, {{goal_progress}}, {{goal_reached}}
	 *
	 * Also sets the recipient defensively if DonationsAutomations was not active.
	 *
	 * @param array $payload   Existing payload.
	 * @param int   $target_id Fundraiser post ID.
	 * @param int   $action_id Action instance ID.
	 *
	 * @return array
	 */
	public static function get_fundraiser_email_payload( array $payload, int $target_id, int $_action_id ): array {
		if ( ! $target_id ) {
			return $payload;
		}

		$target_amount = (float) get_post_meta( $target_id, '_ambh_target_amount', true );
		$cache         = FundraiserCache::get_or_update_cache( $target_id );
		$total_raised  = (float) ( $cache['total_amount'] ?? 0.0 );

		$progress_pct = 0.0;
		if ( $target_amount > 0 ) {
			$progress_pct = round( ( $total_raised / $target_amount ) * 100, 1 );
		}

		// Ensure recipient is set if DonationsAutomations didn’t run (defensive).
		if ( empty( $payload['to'] ) ) {
			$post  = get_post( $target_id );
			$owner = $post ? get_userdata( (int) $post->post_author ) : null;
			if ( $owner ) {
				$payload['to']                                    = $owner->user_email;
				$payload['template_vars']['{{fundraiser_title}}'] = $post->post_title;
				$payload['template_vars']['{{fundraiser_url}}']   = (string) get_permalink( $post->ID );
				$payload['template_vars']['{{owner_first_name}}'] = $owner->first_name ? $owner->first_name : $owner->display_name;
				$payload['template_vars']['{{owner_email}}']      = $owner->user_email;
			}
		}

		$payload['template_vars'] = array_merge(
			$payload['template_vars'] ?? array(),
			array(
				'{{target_amount}}' => $target_amount > 0 ? number_format( $target_amount, 2 ) : '—',
				'{{goal_progress}}' => $progress_pct . '%',
				'{{goal_reached}}'  => $target_amount > 0 && $total_raised >= $target_amount ? 'yes' : 'no',
			)
		);

		return $payload;
	}

	/**
	 * Register template tokens for the automation variable picker (fundraiser entity).
	 *
	 * @param array  $tokens      Existing tokens.
	 * @param string $entity_type Entity type being queried.
	 *
	 * @return array
	 */
	public static function register_template_tokens( array $tokens, string $entity_type ): array {
		if ( 'fundraiser' !== $entity_type ) {
			return $tokens;
		}

		return array_merge(
			$tokens,
			array(
				'{{fundraiser_title}}' => array( 'label' => __( 'Fundraiser title', 'ambassadorhub' ) ),
				'{{fundraiser_url}}'   => array( 'label' => __( 'Fundraiser URL', 'ambassadorhub' ) ),
				'{{owner_first_name}}' => array( 'label' => __( 'Owner first name', 'ambassadorhub' ) ),
				'{{owner_last_name}}'  => array( 'label' => __( 'Owner last name', 'ambassadorhub' ) ),
				'{{owner_email}}'      => array( 'label' => __( 'Owner email', 'ambassadorhub' ) ),
				'{{target_amount}}'    => array( 'label' => __( 'Target amount', 'ambassadorhub' ) ),
				'{{goal_progress}}'    => array( 'label' => __( 'Goal progress (%)', 'ambassadorhub' ) ),
				'{{goal_reached}}'     => array( 'label' => __( 'Goal reached (yes/no)', 'ambassadorhub' ) ),
			)
		);
	}
}
