<?php
/**
 * Payout Schedule Functions
 *
 * @package     AffiliateWP
 * @subpackage  Core
 * @copyright   Copyright (c) 2026, Awesome Motive, Inc
 * @license     http://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since       2.35.0
 */

/**
 * Retrieves a payout schedule object.
 *
 * @since 2.35.0
 *
 * @param int|object $schedule Schedule ID or object.
 * @return AffWP\Affiliate\PayoutSchedule|false Schedule object if found, otherwise false.
 */
function affwp_get_payout_schedule( $schedule = 0 ) {

	if ( is_object( $schedule ) && isset( $schedule->schedule_id ) ) {
		$schedule_id = $schedule->schedule_id;
	} elseif ( is_numeric( $schedule ) ) {
		$schedule_id = absint( $schedule );
	} else {
		return false;
	}

	return affiliate_wp()->affiliates->payout_schedules->get_object( $schedule_id );
}

/**
 * Retrieves payout schedules.
 *
 * @since 2.35.0
 *
 * @param array $args Optional. Arguments for querying schedules. See Affiliate_WP_Payout_Schedules_DB::get_schedules().
 * @return array Array of schedule objects.
 */
function affwp_get_payout_schedules( $args = array() ) {
	return affiliate_wp()->affiliates->payout_schedules->get_schedules( $args );
}

/**
 * English ordinal for a day-of-month number (1 → "1st", 22 → "22nd").
 *
 * Shared by the schedule drawer's day picker, the schedules list cadence
 * column, and the JS summary sentence so all three read the same.
 *
 * @since 2.35.0
 *
 * @param int $number Day of the month.
 * @return string Number with its ordinal suffix.
 */
function affwp_payout_schedule_ordinal( $number ) {
	$number = (int) $number;
	$suffix = 'th';

	// 11th–13th are the exceptions that always take "th".
	if ( ! in_array( $number % 100, array( 11, 12, 13 ), true ) ) {
		switch ( $number % 10 ) {
			case 1:
				$suffix = 'st';
				break;
			case 2:
				$suffix = 'nd';
				break;
			case 3:
				$suffix = 'rd';
				break;
		}
	}

	return $number . $suffix;
}

/**
 * Weekday labels keyed by day-of-week number (0 = Sunday … 6 = Saturday),
 * matching affwp_calculate_next_run()'s numbering. Natural Sun→Sat order; the
 * drawer's picker reorders from the site's "Week starts on" setting.
 *
 * @since 2.35.0
 *
 * @return array<int,string> Localized weekday names keyed 0–6.
 */
function affwp_payout_schedule_weekday_labels() {
	global $wp_locale;

	$labels = array();
	for ( $dow = 0; $dow < 7; $dow++ ) {
		$labels[ $dow ] = $wp_locale ? $wp_locale->get_weekday( $dow ) : gmdate( 'l', strtotime( "Sunday +{$dow} days" ) );
	}

	return $labels;
}

/**
 * Day-of-month options for the schedule drawer's "Monthly" picker: ordinals
 * 1st–28th (28 is the highest day every month has) plus a "last day" sentinel
 * (99). Keyed by the stored day number.
 *
 * @since 2.35.0
 *
 * @return array<int,string> Ordinal labels keyed by day number.
 */
function affwp_payout_schedule_monthday_options() {
	$options = array();
	for ( $d = 1; $d <= 28; $d++ ) {
		$options[ $d ] = affwp_payout_schedule_ordinal( $d );
	}
	// Terse under the "Monthly" toggle (1st–28th sit above it); the summary + list spell out "last day of each month".
	$options[99] = __( 'Last day', 'affiliate-wp' );

	return $options;
}

/**
 * Retrieves the ID of the payout schedule that generated a batch, if any.
 *
 * Automatic runs stamp their schedule ID onto the batch row (see
 * Automatic_Payout_Processor); manual batches carry 0.
 *
 * @since 2.35.0
 *
 * @param int|\AffWP\Affiliate\Payout_Batch $batch Batch ID or object.
 * @return int Schedule ID, or 0 for a manual batch.
 */
function affwp_get_payout_batch_schedule_id( $batch ) {

	$batch = affwp_get_payout_batch( $batch );

	return $batch ? absint( $batch->schedule_id ?? 0 ) : 0;
}

/**
 * Hover-hint attributes for a Last-run cell badge link.
 *
 * Uses the shared dark Tippy tooltip (the same treatment as the list-table
 * row-action rails) when the admin tooltip helper is loaded; falls back to a
 * native title in contexts that don't load includes/admin/tooltips.php.
 * The schedules JS re-runs the Tippy scan after the run-now cell swap.
 *
 * @since 2.35.0
 * @access private
 *
 * @param string $label     Tooltip text.
 * @param string $max_width Optional. CSS max-width (e.g. '220px') so longer hints wrap.
 * @return string Attribute string with a leading space.
 */
function affwp_payout_schedule_badge_tooltip_attrs( $label, $max_width = '' ) {

	if ( function_exists( 'affwp_dark_tooltip_html' ) ) {
		return sprintf(
			' data-tooltip-html="%s" data-tooltip-arrow="false"',
			esc_attr( affwp_dark_tooltip_html( $label, $max_width ) )
		);
	}

	return sprintf( ' title="%s"', esc_attr( $label ) );
}

/**
 * Renders the "Last run" cell for a schedule: when it last ran and how it went.
 *
 * Shared by the list-table column and the AJAX row-cell refresh so both render
 * identically. Deliberately quiet: a successful run shows just the date (a green
 * badge on every row is noise that competes with the Status column). Only the
 * exceptions — Partial / Failed — get a colored badge, linked to the batch that
 * run produced so the owner can drill into who was paid and who wasn't. Skipped
 * ("nothing eligible") and in-progress runs show muted text, not an alarm.
 *
 * @since 2.35.0
 *
 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
 * @return string Cell HTML.
 */
function affwp_payout_schedule_last_run_cell( $schedule ) {

	if ( empty( $schedule->last_run ) || '0000-00-00 00:00:00' === $schedule->last_run ) {
		return '<span class="text-gray-400">' . esc_html__( 'Not yet run', 'affiliate-wp' ) . '</span>';
	}

	// last_run is stored in UTC; wp_date() renders it in the site timezone.
	$date = '<span class="tabular-nums">' . esc_html( wp_date( get_option( 'date_format' ), strtotime( $schedule->last_run ) ) ) . '</span>';

	$logs = affiliate_wp()->affiliates->payout_schedule_logs->get_logs( array(
		'schedule_id' => (int) $schedule->schedule_id,
		'number'      => 1,
	) );

	if ( empty( $logs ) ) {
		return $date;
	}

	$log = $logs[0];

	/*
	 * A run that produced a batch: the batch is the live record of what was paid
	 * (recount() keeps its counts current as async payouts settle with the
	 * provider), so read the outcome from there rather than the dispatch-time
	 * snapshot the log froze. This is why a run that dispatched cleanly but later
	 * had gateway failures shows Partial here instead of a stale success.
	 */
	if ( ! empty( $log->batch_id ) ) {
		$batch = affwp_get_payout_batch( (int) $log->batch_id );

		if ( $batch ) {

			// Partial/Failed: the only outcomes that warrant a colored badge; link it to the batch.
			if ( 'partial' === $batch->status || 'failed' === $batch->status ) {
				$badge = affwp_get_badge( array(
					'text'    => 'failed' === $batch->status ? __( 'Failed', 'affiliate-wp' ) : __( 'Partial', 'affiliate-wp' ),
					'variant' => 'failed' === $batch->status ? 'danger' : 'warning',
					'size'    => 'xs',
				) );

				$badge = sprintf(
					'<a href="%s" class="no-underline hover:opacity-80"%s>%s</a>',
					esc_url( affwp_admin_url( 'payouts', array( 'tab' => 'batches', 'batch_id' => (int) $batch->batch_id ) ) ),
					affwp_payout_schedule_badge_tooltip_attrs( __( 'View the payout batch from this run', 'affiliate-wp' ) ),
					$badge
				);

				return $date . '<div class="mt-0.5">' . $badge . '</div>';
			}

			// Async payouts still settling with the provider.
			if ( 'processing' === $batch->status ) {
				return $date . '<div class="mt-0.5 text-xs text-gray-400">' . esc_html__( 'Processing…', 'affiliate-wp' ) . '</div>';
			}

			// Completed — the date alone says it ran and paid; no badge needed.
			return $date;
		}
	}

	// No batch: nothing eligible (skipped), still running, or a dispatch failure.
	if ( 'skipped' === $log->status ) {

		/*
		 * A skip can hide affiliates held back by a previous failed payout — money
		 * stuck until an admin acts. Surface them as a short linked badge: hover
		 * explains, click opens the blocking batch (where retry lives).
		 */
		$data    = json_decode( (string) $log->errors, true );
		$blocked = is_array( $data ) && ! empty( $data['blocked_affiliates'] ) ? count( (array) $data['blocked_affiliates'] ) : 0;

		if ( $blocked > 0 ) {
			$batch_id = ! empty( $data['blocking_batch_id'] ) ? absint( $data['blocking_batch_id'] ) : 0;

			// One blocking batch → straight to its drawer; several → the Batches list
			// already filtered to Failed, so the click lands on what needs review.
			$href = $batch_id
				? affwp_admin_url( 'payouts', array( 'tab' => 'batches', 'batch_id' => $batch_id ) )
				: affwp_admin_url( 'payouts', array( 'tab' => 'batches', 'status' => 'failed' ) );

			$badge = affwp_get_badge( array(
				'text'    => sprintf(
					/* translators: %s: number of affiliates held back by a failed payout */
					_n( '%s blocked', '%s blocked', $blocked, 'affiliate-wp' ),
					number_format_i18n( $blocked )
				),
				'variant' => 'warning',
				'size'    => 'xs',
			) );

			$badge = sprintf(
				'<a href="%s" class="no-underline hover:opacity-80"%s>%s</a>',
				esc_url( $href ),
				affwp_payout_schedule_badge_tooltip_attrs(
					sprintf(
						/* translators: %s: number of affiliates held back by a failed payout */
						_n(
							'%s affiliate was skipped because a previous payout failed and needs attention. Click to review.',
							'%s affiliates were skipped because a previous payout failed and needs attention. Click to review.',
							$blocked,
							'affiliate-wp'
						),
						number_format_i18n( $blocked )
					),
					'220px'
				),
				$badge
			);

			// The badge replaces the muted "Nothing to pay" line, matching the cell's
			// Partial/Failed grammar: when the outcome warrants a badge, it stands alone.
			return $date . '<div class="mt-0.5">' . $badge . '</div>';
		}

		return $date . '<div class="mt-0.5 text-xs text-gray-400">' . esc_html__( 'Nothing to pay', 'affiliate-wp' ) . '</div>';
	}

	if ( 'processing' === $log->status ) {
		return $date . '<div class="mt-0.5 text-xs text-gray-400">' . esc_html__( 'Running…', 'affiliate-wp' ) . '</div>';
	}

	if ( 'failed' === $log->status ) {
		return $date . '<div class="mt-0.5">' . affwp_get_badge( array(
			'text'    => __( 'Failed', 'affiliate-wp' ),
			'variant' => 'danger',
			'size'    => 'xs',
		) ) . '</div>';
	}

	// Success — the date alone says it ran and paid; no badge needed.
	return $date;
}

/**
 * Status-badge cell for a schedule. Shared by the list table column and the AJAX
 * row refresh so an in-place update matches a fresh render (incl. the gated state).
 *
 * Gated (non-Pro) schedules can't run, so they show "Not running" rather than a
 * green "Active" that lies. Display only — the stored status is left intact.
 *
 * @since 2.35.0
 *
 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
 * @return string Cell HTML.
 */
function affwp_payout_schedule_status_cell( $schedule ) {

	if ( function_exists( 'affwp_can_access_pro_features' ) && ! affwp_can_access_pro_features() ) {
		return affwp_get_status_badge( 'inactive', array( 'text' => __( 'Not running', 'affiliate-wp' ) ) );
	}

	if ( 'active' === $schedule->status ) {
		return affwp_get_status_badge( 'active' );
	}

	return affwp_get_status_badge( 'inactive', array( 'text' => __( 'Paused', 'affiliate-wp' ) ) );
}

/**
 * Next-run cell for a schedule. Shared by the list table column and the AJAX row
 * refresh. Gated schedules won't run, so a concrete date would be a false promise.
 *
 * @since 2.35.0
 *
 * @param \AffWP\Affiliate\PayoutSchedule $schedule Schedule object.
 * @return string Cell HTML.
 */
function affwp_payout_schedule_next_run_cell( $schedule ) {

	if ( function_exists( 'affwp_can_access_pro_features' ) && ! affwp_can_access_pro_features() ) {
		return '&mdash;';
	}

	if ( empty( $schedule->next_run ) || '0000-00-00 00:00:00' === $schedule->next_run ) {
		return '&mdash;';
	}

	// next_run is stored in UTC; wp_date() renders it in the site timezone.
	return '<span class="tabular-nums">' . esc_html( wp_date( get_option( 'date_format' ), strtotime( $schedule->next_run ) ) ) . '</span>';
}

/**
 * Adds a new payout schedule.
 *
 * @since 2.35.0
 *
 * @param array $args {
 *     Arguments for adding a new schedule.
 *
 *     @type string $name                   Schedule name. Required.
 *     @type string $frequency              Frequency: 'weekly' or 'monthly'. Default 'monthly'.
 *     @type int    $frequency_day          Day of week (1-7) or month (1-31, 99=last day). Default 1.
 *     @type string $affiliate_group_ids    JSON-encoded array of group IDs to target. Default null (no group targeting).
 *     @type string $affiliate_ids          JSON-encoded array of specific affiliate IDs. Default null.
 *     @type float  $minimum_amount         Minimum unpaid balance to trigger payout. Default 0 (no minimum).
 *     @type int    $bypass_holding_period  Whether to bypass the referral holding period. Default 0.
 *     @type string $status                 Schedule status: 'active' or 'paused'. Default 'active'.
 *     @type string $next_run               Next scheduled run datetime (Y-m-d H:i:s).
 * }
 * @return int|false Schedule ID if successfully added, otherwise false.
 */
function affwp_add_payout_schedule( $args = array() ) {

	$schedule_id = affiliate_wp()->affiliates->payout_schedules->add( $args );

	if ( ! $schedule_id ) {
		return false;
	}

	// If active and no next_run was stored, calculate and save it. A fresh insert
	// leaves the datetime column as the MySQL zero-date, which is not empty() —
	// treat it as unset so the first run actually gets scheduled.
	$schedule = affwp_get_payout_schedule( $schedule_id );

	$has_next_run = ! empty( $schedule->next_run ) && '0000-00-00 00:00:00' !== $schedule->next_run;

	if ( $schedule && 'active' === $schedule->status && ! $has_next_run ) {
		$next_run = affwp_calculate_next_run( $schedule->frequency, $schedule->frequency_day );

		if ( $next_run ) {
			affiliate_wp()->affiliates->payout_schedules->update( $schedule_id, array( 'next_run' => $next_run ), '', 'payout_schedule' );
			affwp_schedule_payout_action( $schedule_id, strtotime( $next_run ) );
		}
	}

	return $schedule_id;
}

/**
 * Updates a payout schedule.
 *
 * @since 2.35.0
 *
 * @param int   $schedule_id Schedule ID.
 * @param array $data        Data to update.
 * @return bool True if updated successfully, false otherwise.
 */
function affwp_update_payout_schedule( $schedule_id, $data = array() ) {

	$schedule_id = absint( $schedule_id );

	if ( empty( $schedule_id ) ) {
		return false;
	}

	$updated = affiliate_wp()->affiliates->payout_schedules->update( $schedule_id, $data, '', 'payout_schedule' );

	if ( $updated ) {
		// If frequency or status changed, recalculate next_run and reschedule.
		if ( isset( $data['frequency'] ) || isset( $data['frequency_day'] ) || isset( $data['status'] ) ) {
			$schedule = affwp_get_payout_schedule( $schedule_id );

			if ( $schedule && 'active' === $schedule->status ) {
				$next_run = affwp_calculate_next_run( $schedule->frequency, $schedule->frequency_day );

				if ( $next_run ) {
					affiliate_wp()->affiliates->payout_schedules->update( $schedule_id, array( 'next_run' => $next_run ), '', 'payout_schedule' );
					affwp_schedule_payout_action( $schedule_id, strtotime( $next_run ) );
				}
			}
		}
	}

	return (bool) $updated;
}

/**
 * Deletes a payout schedule.
 *
 * @since 2.35.0
 *
 * @param int $schedule_id Schedule ID.
 * @return bool True if deleted, false otherwise.
 */
function affwp_delete_payout_schedule( $schedule_id ) {

	$schedule_id = absint( $schedule_id );

	if ( empty( $schedule_id ) ) {
		return false;
	}

	return (bool) affiliate_wp()->affiliates->payout_schedules->delete( $schedule_id, 'payout_schedule' );
}

/**
 * Pauses a payout schedule.
 *
 * @since 2.35.0
 *
 * @param int $schedule_id Schedule ID.
 * @return bool True if paused, false otherwise.
 */
function affwp_pause_payout_schedule( $schedule_id ) {

	$schedule_id = absint( $schedule_id );

	if ( empty( $schedule_id ) ) {
		return false;
	}

	return (bool) affiliate_wp()->affiliates->payout_schedules->update(
		$schedule_id,
		array( 'status' => 'paused' ),
		'',
		'payout_schedule'
	);
}

/**
 * Resumes a paused payout schedule.
 *
 * @since 2.35.0
 *
 * @param int $schedule_id Schedule ID.
 * @return bool True if resumed, false otherwise.
 */
function affwp_resume_payout_schedule( $schedule_id ) {

	$schedule_id = absint( $schedule_id );

	if ( empty( $schedule_id ) ) {
		return false;
	}

	$updated = (bool) affiliate_wp()->affiliates->payout_schedules->update(
		$schedule_id,
		array( 'status' => 'active' ),
		'',
		'payout_schedule'
	);

	if ( $updated ) {
		$schedule = affwp_get_payout_schedule( $schedule_id );

		if ( $schedule ) {
			$next_run = affwp_calculate_next_run( $schedule->frequency, $schedule->frequency_day );

			if ( $next_run ) {
				affiliate_wp()->affiliates->payout_schedules->update( $schedule_id, array( 'next_run' => $next_run ), '', 'payout_schedule' );
				affwp_schedule_payout_action( $schedule_id, strtotime( $next_run ) );
			}
		}
	}

	return $updated;
}

/**
 * Retrieves a payout schedule log object.
 *
 * @since 2.35.0
 *
 * @param int|object $log Log ID or object.
 * @return AffWP\Affiliate\PayoutScheduleLog|false Log object if found, otherwise false.
 */
function affwp_get_payout_schedule_log( $log = 0 ) {

	if ( is_object( $log ) && isset( $log->log_id ) ) {
		$log_id = $log->log_id;
	} elseif ( is_numeric( $log ) ) {
		$log_id = absint( $log );
	} else {
		return false;
	}

	return affiliate_wp()->affiliates->payout_schedule_logs->get_object( $log_id );
}

/**
 * Calculates the next run datetime for a given frequency and day.
 *
 * @since 2.35.0
 *
 * @param string $frequency     Frequency: 'weekly' or 'monthly'.
 * @param int    $frequency_day Day of week (0=Sunday, 1=Monday ... 6=Saturday) for weekly,
 *                              or day of month (1-31, 99=last day) for monthly. If the month
 *                              has fewer days than the target, falls back to the last day.
 * @return string|false Next run datetime (Y-m-d H:i:s) in UTC for midnight of the target day in the site timezone, or false on failure.
 */
function affwp_calculate_next_run( $frequency, $frequency_day ) {

	// The chosen weekday / day-of-month is the admin's local calendar day, so compute in
	// the site timezone; the return is converted to UTC below for storage + Action Scheduler.
	$timezone = function_exists( 'wp_timezone' ) ? wp_timezone() : new DateTimeZone( 'UTC' );
	$now      = new DateTimeImmutable( 'now', $timezone );

	switch ( $frequency ) {

		case 'weekly':
			// $frequency_day: 0=Sunday through 6=Saturday.
			$target_dow  = absint( $frequency_day ) % 7;
			$current_dow = (int) $now->format( 'w' );

			$days_ahead = $target_dow - $current_dow;

			if ( $days_ahead <= 0 ) {
				$days_ahead += 7;
			}

			$next = $now->modify( "+{$days_ahead} days" )->setTime( 0, 0, 0 );
			break;

		case 'monthly':
			// 99 = last day. Clamp overlong days (e.g. 31 in Feb) ourselves: setDate() silently overflows (Feb 31 -> Mar 3).
			$target_dom  = absint( $frequency_day );
			$is_last_day = ( 99 === $target_dom );

			if ( ! $is_last_day ) {
				$target_dom = max( 1, min( 31, $target_dom ) );
			}

			$current_dom     = (int) $now->format( 'j' );
			$days_this_month = (int) $now->format( 't' );
			$effective_dom   = $is_last_day ? $days_this_month : min( $target_dom, $days_this_month );

			if ( $current_dom < $effective_dom ) {
				// Target day still coming up this month.
				$next = $now->setDate( (int) $now->format( 'Y' ), (int) $now->format( 'n' ), $effective_dom )->setTime( 0, 0, 0 );
			} else {
				// Already passed this month; roll to next month and recompute effective day.
				$next_month      = $now->modify( 'first day of next month' );
				$days_next_month = (int) $next_month->format( 't' );
				$effective_dom   = $is_last_day ? $days_next_month : min( $target_dom, $days_next_month );
				$next            = $next_month->setDate( (int) $next_month->format( 'Y' ), (int) $next_month->format( 'n' ), $effective_dom )->setTime( 0, 0, 0 );
			}
			break;

		default:
			return false;
	}

	return $next->setTimezone( new DateTimeZone( 'UTC' ) )->format( 'Y-m-d H:i:s' );
}

/**
 * Schedules a payout run via Action Scheduler.
 *
 * @since 2.35.0
 *
 * @param int $schedule_id Schedule ID.
 * @param int $timestamp   Unix timestamp for when to run.
 * @return int|false Action Scheduler action ID, or false on failure.
 */
function affwp_schedule_payout_action( $schedule_id, $timestamp ) {

	$schedule_id = absint( $schedule_id );

	if ( empty( $schedule_id ) || empty( $timestamp ) ) {
		return false;
	}

	if ( ! function_exists( 'as_schedule_single_action' ) ) {
		return false;
	}

	// Unschedule any existing pending action for this schedule to avoid duplicates.
	as_unschedule_action( 'affwp_run_automatic_payout', array( $schedule_id ), 'affiliatewp' );

	return as_schedule_single_action( $timestamp, 'affwp_run_automatic_payout', array( $schedule_id ), 'affiliatewp' );
}

/**
 * Eligibility gate for the Payout Schedules in-product discovery.
 *
 * Cheapest-first: capability, then a site-level "already using schedules" check
 * so an adopted site short-circuits (matches the intl-payouts discovery's
 * "already enabled" gate). Not Pro-gated on purpose — a gated customer seeing
 * the nudge lands on the schedules tab's aspirational empty state + upgrade
 * card, so the discovery doubles as upsell surfacing.
 *
 * @since 2.35.0
 *
 * @return bool
 */
function affwp_payout_schedules_discovery_eligible() {

	if ( ! function_exists( 'affwp_discovery_capability' ) || ! current_user_can( affwp_discovery_capability() ) ) {
		return false;
	}

	return 0 === (int) affiliate_wp()->affiliates->payout_schedules->count();
}

/**
 * Registers the Payout Schedules discovery with the framework.
 *
 * Surfaces a dot on the Payout Schedules tab and a one-time notice on the
 * Payouts list, whose CTA navigates to the tab. Copy is plan-aware: entitled
 * customers get "New / set up"; gated customers get "New in Pro / explore",
 * since the tab is a Pro feature for them.
 *
 * @since 2.35.0
 *
 * @param AffiliateWP_Feature_Discovery $discovery Discovery coordinator.
 */
function affwp_payout_schedules_register_discovery( $discovery ) {

	$can_access = ! function_exists( 'affwp_can_access_pro_features' ) || affwp_can_access_pro_features();

	$discovery->register(
		'payout_schedules',
		array(
			'introduced_in' => '2.35.0',
			'eligibility'   => 'affwp_payout_schedules_discovery_eligible',
			'docs_url'      => 'https://affiliatewp.com/docs/automatic-payouts/',
			/*
			 * CTA target matches the plan-aware copy: an entitled user clicked
			 * "Set up a schedule", so land them on the tab with the create drawer
			 * already open (?new_schedule=1, which the drawer auto-opens on load).
			 * A gated user clicked "Explore" — land on the tab's aspirational empty
			 * state (the sell surface + upgrade path), not straight into the gate.
			 */
			'cta_target'    => $can_access
				? affwp_admin_url( 'payouts', array( 'tab' => 'schedules', 'new_schedule' => 1 ) )
				: affwp_admin_url( 'payouts', array( 'tab' => 'schedules' ) ),
			'cta_navigate'  => true, // Full-page navigation to the tab, not a same-page hash scroll.
			'notice'        => array(
				'eyebrow'   => $can_access ? __( 'New', 'affiliate-wp' ) : __( 'New in Pro', 'affiliate-wp' ),
				'heading'   => __( 'Pay your affiliates automatically, on a schedule', 'affiliate-wp' ),
				'body'      => __( 'Set a schedule once and every eligible affiliate is paid on time, each through their own payout method. Build a payout, then save it as a recurring schedule.', 'affiliate-wp' ),
				'cta_label' => $can_access ? __( 'Set up a schedule', 'affiliate-wp' ) : __( 'Explore Payout Schedules', 'affiliate-wp' ),
			),
		)
	);
}
add_action( 'affwp_register_feature_discoveries', 'affwp_payout_schedules_register_discovery' );
