<?php
/**
 * UI Component Functions
 *
 * @package     AffiliateWP
 * @subpackage  Functions
 * @copyright   Copyright (c) 2025, Sandhills Development, LLC
 * @license     http://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since       2.29.0
 */

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

/**
 * Get the UI Components instance
 *
 * @since 2.29.0
 *
 * @return \AffiliateWP\Utils\UI_Components The UI Components instance.
 */
function affwp_ui_components() {
	return \AffiliateWP\Utils\UI_Components::get_instance();
}

/**
 * Get the UI Components instance (shorthand alias)
 *
 * @since 2.29.0
 *
 * @return \AffiliateWP\Utils\UI_Components The UI Components instance.
 */
function affwp_ui() {
	return \AffiliateWP\Utils\UI_Components::get_instance();
}

/**
 * Derive up-to-two-letter initials from a display name.
 *
 * Returns the first letter of the first and last word ("Ada Lovelace" => "AL",
 * "Cher" => "C"). Falls back to the first letter of $fallback (e.g. a username)
 * when the name is empty, so avatar bubbles always have something to show.
 *
 * @since 2.34.0
 *
 * @param string $name     Display name.
 * @param string $fallback Value to derive a single initial from when $name is empty. Optional.
 * @return string Uppercase initials, or '' when neither yields a letter.
 */
function affwp_get_initials_from_name( $name, $fallback = '' ) {

	$initials = '';

	if ( '' !== trim( (string) $name ) ) {
		$parts    = preg_split( '/\s+/', trim( (string) $name ), -1, PREG_SPLIT_NO_EMPTY );
		$initials = strtoupper( mb_substr( $parts[0] ?? '', 0, 1 ) );
		if ( count( $parts ) > 1 ) {
			$initials .= strtoupper( mb_substr( (string) end( $parts ), 0, 1 ) );
		}
	}

	if ( '' === $initials && '' !== trim( (string) $fallback ) ) {
		$initials = strtoupper( mb_substr( trim( (string) $fallback ), 0, 1 ) );
	}

	return $initials;
}

/**
 * Render an avatar component
 *
 * @since 2.34.0
 *
 * @param array $args Avatar arguments. See {@see \AffiliateWP\Utils\Traits\Avatars::render_avatar()}.
 * @return string The rendered avatar HTML.
 */
function affwp_render_avatar( $args = [] ) {
	return affwp_ui_components()->render( 'avatar', $args );
}

/**
 * Display an avatar component
 *
 * @since 2.34.0
 *
 * @param array $args Avatar arguments. See affwp_render_avatar() for details.
 */
function affwp_avatar( $args = [] ) {
	echo affwp_render_avatar( $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- helper outputs escaped markup.
}

/**
 * Render a button component
 *
 * @since 2.29.0
 *
 * @param array $args {
 *     Button arguments.
 *
 *     @type string       $text       Button text. Required unless icon is provided.
 *     @type string       $variant    Button variant (primary, secondary, danger, ghost, success, warning). Default 'secondary'.
 *     @type string       $size       Button size (xs, sm, md, lg, xl). Default 'sm'.
 *     @type string       $href       URL for link buttons. Optional.
 *     @type string       $type       Button type attribute (button, submit, reset). Default 'button'.
 *     @type string|array $icon       Icon name or icon configuration array. Optional.
 *     @type array        $attributes Additional HTML attributes (supports AlpineJS directives). Optional.
 *     @type string       $class      Additional CSS classes. Optional.
 *     @type bool         $disabled   Whether the button is disabled. Default false.
 *     @type string       $id         Button ID attribute. Optional.
 *     @type string       $name       Button name attribute. Optional.
 *     @type string       $value      Button value attribute. Optional.
 * }
 * @return string The rendered button HTML.
 */
function affwp_render_button( $args = [] ) {
	return affwp_ui_components()->render( 'button', $args );
}

/**
 * Display a button component
 *
 * @since 2.29.0
 *
 * @param array $args Button arguments. See affwp_render_button() for details.
 */
function affwp_button( $args = [] ) {
	echo affwp_render_button( $args );
}

/**
 * Render a primary button
 *
 * @since 2.29.0
 *
 * @param string $text       Button text.
 * @param array  $args       Additional button arguments.
 * @return string The rendered button HTML.
 */
function affwp_render_primary_button( $text, $args = [] ) {
	$args['text']    = $text;
	$args['variant'] = 'primary';
	return affwp_render_button( $args );
}

/**
 * Display a primary button
 *
 * @since 2.29.0
 *
 * @param string $text Button text.
 * @param array  $args Additional button arguments.
 */
function affwp_primary_button( $text, $args = [] ) {
	echo affwp_render_primary_button( $text, $args );
}

/**
 * Render a copy to clipboard button
 *
 * @since 2.29.0
 *
 * @param array $args {
 *     Button arguments.
 *
 *     @type string       $content          Content to copy to clipboard. Required.
 *     @type string       $button_text      Initial button text. Default 'Copy'.
 *     @type string       $success_text     Text shown on successful copy. Default 'Copied!'.
 *     @type string       $error_text       Text shown on error. Default 'Failed to copy'.
 *     @type string       $variant          Button variant. Default 'secondary'.
 *     @type string       $size             Button size. Default 'sm'.
 *     @type string|array $icon             Icon to display. Default none.
 *     @type int          $success_duration Duration to show success message in ms. Default 2000.
 *     @type string       $alpine_model     Alpine.js model name for state management. Default 'copyButton'.
 *     @type array        $attributes       Additional HTML attributes. Optional.
 *     @type string       $class            Additional CSS classes. Optional.
 * }
 * @return string The rendered button HTML with Alpine.js integration.
 */
function affwp_render_copy_button( $args = [] ) {
	$defaults = [
		'content'          => '',
		'button_text'      => __( 'Copy', 'affiliate-wp' ),
		'success_text'     => __( 'Copied!', 'affiliate-wp' ),
		'error_text'       => __( 'Failed to copy', 'affiliate-wp' ),
		'variant'          => 'secondary',
		'size'             => 'sm',
		'icon'             => '',
		'success_duration' => 2000,
		'alpine_model'     => 'copyButton',
		'attributes'       => [],
		'class'            => '',
	];

	$args = wp_parse_args( $args, $defaults );

	// Ensure we have content to copy.
	if ( empty( $args['content'] ) ) {
		return '';
	}

	// Escape content for JavaScript.
	$content_escaped = esc_js( $args['content'] );

	// Build Alpine.js component.
	$alpine_data = sprintf(
		'{
			buttonText: \'%s\',
			originalText: \'%s\',
			isProcessing: false,
			async copyToClipboard() {
				if (this.isProcessing) return;

				this.isProcessing = true;

				try {
					await navigator.clipboard.writeText(\'%s\');
					this.buttonText = \'%s\';
					setTimeout(() => {
						this.buttonText = this.originalText;
						this.isProcessing = false;
					}, %d);
				} catch (err) {
					this.buttonText = \'%s\';
					setTimeout(() => {
						this.buttonText = this.originalText;
						this.isProcessing = false;
					}, %d);
				}
			}
		}',
		esc_js( $args['button_text'] ),
		esc_js( $args['button_text'] ),
		$content_escaped,
		esc_js( $args['success_text'] ),
		intval( $args['success_duration'] ),
		esc_js( $args['error_text'] ),
		intval( $args['success_duration'] )
	);

	// Build Alpine.js attributes.
	$alpine_attrs = [
		'@click'    => 'copyToClipboard()',
		':disabled' => 'isProcessing',
	];

	// Merge with custom attributes.
	$attributes = array_merge( $args['attributes'], $alpine_attrs );

	// Create the button.
	$button_args = [
		'text'              => $args['button_text'],
		'variant'           => $args['variant'],
		'size'              => $args['size'],
		'icon'              => $args['icon'],
		'alpine_text'       => 'buttonText',
		'attributes'        => $attributes,
		'class'             => $args['class'],
		'no_alpine_wrapper' => true, // We'll wrap it ourselves.
	];

	$button_html = affwp_render_button( $button_args );

	// Wrap in Alpine component.
	return sprintf(
		'<div x-data="%s">%s</div>',
		esc_attr( $alpine_data ),
		$button_html
	);
}

/**
 * Display a copy to clipboard button
 *
 * @since 2.29.0
 *
 * @param array $args Button arguments. See affwp_render_copy_button() for details.
 */
function affwp_copy_button( $args = [] ) {
	echo affwp_render_copy_button( $args );
}

/**
 * Render a plugin activation button with AJAX capability
 *
 * @since 2.29.0
 *
 * @param array $args {
 *     Button arguments.
 *
 *     @type string $plugin_file    Plugin file path (e.g., 'plugin-name/plugin-name.php'). Required.
 *     @type string $button_text    Initial button text. Default 'Activate Plugin'.
 *     @type string $activating_text Text shown during activation. Default 'Activating...'.
 *     @type string $success_text   Text shown on success. Default 'Activated'.
 *     @type string $variant        Button variant. Default 'secondary'.
 *     @type string $size           Button size. Default 'sm'.
 *     @type string $alpine_model   Alpine.js model name for state management. Default 'pluginActivation'.
 *     @type string $success_callback JavaScript function to call on success. Optional.
 *     @type string $error_callback JavaScript function to call on error. Optional.
 *     @type array  $attributes     Additional HTML attributes. Optional.
 * }
 * @return string The rendered button HTML with Alpine.js integration.
 */
function affwp_render_plugin_activation_button( $args = [] ) {
	$defaults = [
		'plugin_file'      => '',
		'button_text'      => __( 'Activate Plugin', 'affiliate-wp' ),
		'activating_text'  => __( 'Activating...', 'affiliate-wp' ),
		'success_text'     => __( 'Activated', 'affiliate-wp' ),
		'variant'          => 'secondary',
		'size'             => 'sm',
		'alpine_model'     => 'pluginActivation',
		'success_callback' => '',
		'error_callback'   => '',
		'attributes'       => [],
	];

	$args = wp_parse_args( $args, $defaults );

	// Ensure we have a plugin file.
	if ( empty( $args['plugin_file'] ) ) {
		return '';
	}

	// Generate unique model name if needed.
	$model_name = $args['alpine_model'];

	// Build Alpine.js attributes.
	$alpine_attrs = [
		'@click'    => 'activatePlugin()',
		':disabled' => 'isActivating',
	];

	// Merge with custom attributes.
	$attributes = array_merge( $args['attributes'], $alpine_attrs );

	// Create the button with dynamic text and loading icon.
	$button_args = [
		'text'         => $args['button_text'],
		'variant'      => $args['variant'],
		'size'         => $args['size'],
		'alpine_text'  => 'buttonText',
		'dynamic_icon' => [
			'name'           => 'loading',
			'show_condition' => 'isActivating',
			'position'       => 'left',
		],
		'attributes'   => $attributes,
	];

	// Build the Alpine.js data and methods.
	$alpine_data = [
		'isActivating' => false,
		'buttonText'   => $args['button_text'],
		'pluginFile'   => $args['plugin_file'],
	];

	$alpine_methods = sprintf(
		"async activatePlugin() {
			if (this.isActivating) return;

			this.isActivating = true;
			this.buttonText = '%s';

			try {
				const response = await fetch('%s', {
					method: 'POST',
					headers: {
						'Content-Type': 'application/x-www-form-urlencoded',
					},
					body: new URLSearchParams({
						action: 'affwp_activate_plugin',
						nonce: '%s',
						plugin: this.pluginFile
					})
				});

				const data = await response.json();

				if (data.success) {
					this.buttonText = '%s';
					this.isActivating = false;
					%s
				} else {
					this.buttonText = '%s';
					this.isActivating = false;
					console.error('Plugin activation failed:', data.data);
					%s
				}
			} catch (error) {
				this.buttonText = '%s';
				this.isActivating = false;
				console.error('Plugin activation error:', error);
				%s
			}
		}",
		esc_js( $args['activating_text'] ),
		esc_url( admin_url( 'admin-ajax.php' ) ),
		wp_create_nonce( 'affiliate-wp-admin' ),
		esc_js( $args['success_text'] ),
		! empty( $args['success_callback'] ) ? $args['success_callback'] : '',
		esc_js( $args['button_text'] ),
		! empty( $args['error_callback'] ) ? $args['error_callback'] : '',
		esc_js( $args['button_text'] ),
		! empty( $args['error_callback'] ) ? $args['error_callback'] : ''
	);

	// Build the complete Alpine.js data object.
	$alpine_data_json   = wp_json_encode( $alpine_data );
	$alpine_data_string = substr( $alpine_data_json, 0, -1 ) . ',' . $alpine_methods . '}';

	// Wrap button in Alpine.js container.
	$html = sprintf(
		'<div x-data="%s">%s</div>',
		esc_attr( $alpine_data_string ),
		affwp_render_button( $button_args )
	);

	return $html;
}

/**
 * Display a plugin activation button with AJAX capability
 *
 * @since 2.29.0
 *
 * @param array $args Button arguments. See affwp_render_plugin_activation_button() for details.
 */
function affwp_plugin_activation_button( $args = [] ) {
	echo affwp_render_plugin_activation_button( $args );
}

/**
 * Render a secondary button
 *
 * @since 2.29.0
 *
 * @param string $text       Button text.
 * @param array  $args       Additional button arguments.
 * @return string The rendered button HTML.
 */
function affwp_render_secondary_button( $text, $args = [] ) {
	$args['text']    = $text;
	$args['variant'] = 'secondary';
	return affwp_render_button( $args );
}

/**
 * Display a secondary button
 *
 * @since 2.29.0
 *
 * @param string $text Button text.
 * @param array  $args Additional button arguments.
 */
function affwp_secondary_button( $text, $args = [] ) {
	echo affwp_render_secondary_button( $text, $args );
}

/**
 * Render a danger button
 *
 * @since 2.29.0
 *
 * @param string $text       Button text.
 * @param array  $args       Additional button arguments.
 * @return string The rendered button HTML.
 */
function affwp_render_danger_button( $text, $args = [] ) {
	$args['text']    = $text;
	$args['variant'] = 'danger';
	return affwp_render_button( $args );
}

/**
 * Display a danger button
 *
 * @since 2.29.0
 *
 * @param string $text Button text.
 * @param array  $args Additional button arguments.
 */
function affwp_danger_button( $text, $args = [] ) {
	echo affwp_render_danger_button( $text, $args );
}

/**
 * Render a modal trigger button
 *
 * @since 2.29.0
 *
 * @param string $modal_id Modal ID to open.
 * @param string $text     Button text.
 * @param array  $args     Additional button arguments.
 * @return string The rendered button HTML.
 */
function affwp_render_modal_button( $modal_id, $text, $args = [] ) {
	$args['text'] = $text;

	// Set up the click handler to open the modal.
	if ( ! isset( $args['attributes'] ) ) {
		$args['attributes'] = [];
	}
	$args['attributes']['@click'] = sprintf( '$store.modals.open(\'%s\')', esc_attr( $modal_id ) );

	// Default to primary variant if not specified.
	if ( ! isset( $args['variant'] ) ) {
		$args['variant'] = 'primary';
	}

	return affwp_render_button( $args );
}

/**
 * Display a modal trigger button
 *
 * @since 2.29.0
 *
 * @param string $modal_id Modal ID to open.
 * @param string $text     Button text.
 * @param array  $args     Additional button arguments.
 */
function affwp_modal_button( $modal_id, $text, $args = [] ) {
	echo affwp_render_modal_button( $modal_id, $text, $args );
}

/**
 * Render a link component
 *
 * @since 2.29.0
 *
 * @param array $args {
 *     Link arguments.
 *
 *     @type string       $text     Link text. Required.
 *     @type string       $href     URL for the link. Required.
 *     @type string       $variant  Link variant (default, primary, muted, danger). Default 'default'.
 *     @type string|array $icon     Icon name or configuration. Optional.
 *     @type bool         $external Whether this is an external link. Auto-detected if not provided.
 *     @type array        $utm      UTM parameters for tracking. Optional.
 * }
 * @return string The rendered link HTML.
 */
function affwp_render_link( $args = [] ) {
	return affwp_ui_components()->render( 'link', $args );
}

/**
 * Display a link component
 *
 * @since 2.29.0
 *
 * @param array $args Link arguments. See affwp_render_link() for details.
 */
function affwp_link( $args = [] ) {
	echo affwp_render_link( $args );
}

/**
 * Render a documentation link
 *
 * @since 2.29.0
 *
 * @param string $doc_path Path after affiliatewp.com/docs/ (e.g., 'stripe-payouts').
 * @param string $text     Link text.
 * @param array  $args     Additional link arguments.
 * @return string The rendered link HTML.
 */
function affwp_render_doc_link( $doc_path, $text, $args = [] ) {
	$args['text'] = $text;
	$args['href'] = 'https://affiliatewp.com/docs/' . ltrim( $doc_path, '/' ) . '/';

	// Default to an external-link icon; docs open in a new tab.
	if ( empty( $args['icon'] ) ) {
		$args['icon'] = 'share-up-right';
	}

	// Add default UTM parameters for documentation.
	if ( empty( $args['utm'] ) ) {
		$args['utm'] = [
			'source' => 'plugin',
			'medium' => 'documentation',
		];
	}

	return affwp_render_link( $args );
}

/**
 * Display a documentation link
 *
 * @since 2.29.0
 *
 * @param string $doc_path Path after affiliatewp.com/docs/.
 * @param string $text     Link text.
 * @param array  $args     Additional link arguments.
 */
function affwp_doc_link( $doc_path, $text, $args = [] ) {
	echo affwp_render_doc_link( $doc_path, $text, $args );
}

/**
 * Render an external link
 *
 * @since 2.29.0
 *
 * @param string $url  The URL.
 * @param string $text Link text.
 * @param array  $args Additional link arguments.
 * @return string The rendered link HTML.
 */
function affwp_render_external_link( $url, $text, $args = [] ) {
	$args['text']     = $text;
	$args['href']     = $url;
	$args['external'] = true;

	// Default to external-link icon if not specified.
	if ( empty( $args['icon'] ) ) {
		$args['icon'] = 'external-link';
	}

	return affwp_render_link( $args );
}

/**
 * Display an external link
 *
 * @since 2.29.0
 *
 * @param string $url  The URL.
 * @param string $text Link text.
 * @param array  $args Additional link arguments.
 */
function affwp_external_link( $url, $text, $args = [] ) {
	echo affwp_render_external_link( $url, $text, $args );
}

/**
 * Render a configure button with animated chevron
 *
 * @since 2.29.0
 *
 * @param array $args {
 *     Button arguments.
 *
 *     @type string $text          Button text. Default 'Configure'.
 *     @type string $panel_var     AlpineJS variable name for panel state. Default 'panelOpen'.
 *     @type string $variant       Button variant. Default 'secondary'.
 *     @type array  $attributes    Additional attributes.
 *     @type bool   $delay_animation Whether to delay animation until after mount. Default false.
 * }
 * @return string The rendered button HTML.
 */
function affwp_render_configure_button( $args = [] ) {
	$defaults = [
		'text'            => __( 'Configure', 'affiliate-wp' ),
		'panel_var'       => 'panelOpen',
		'panel_id'        => '',
		'method_name'     => '',
		'variant'         => 'secondary',
		'attributes'      => [],
		'delay_animation' => false,
	];

	$args = wp_parse_args( $args, $defaults );

	// Setup the icon with animation.
	$icon_class = '';
	if ( $args['delay_animation'] ) {
		// For delayed animation, we'll add the transition class conditionally
		$icon_class = 'affwp-chevron-delayed';
	}

	$args['icon'] = [
		'name'     => 'chevron-right',
		'position' => 'right',
		'animate'  => ! $args['delay_animation'], // Disable if delaying
		'rotation' => sprintf( "%s ? '-rotate-90' : ''", $args['panel_var'] ),
		'class'    => $icon_class,
	];

	// Add initialization for delayed animation
	if ( $args['delay_animation'] && ! isset( $args['attributes']['x-init'] ) ) {
		$args['attributes']['x-init'] = 'setTimeout(() => { if ($el.querySelector("svg.affwp-chevron-delayed")) $el.querySelector("svg.affwp-chevron-delayed").classList.add("transition-transform", "duration-200"); }, 50)';
	}

	// Add ARIA attributes for accessibility.
	if ( ! empty( $args['panel_id'] ) ) {
		$args['attributes']['aria-controls'] = $args['panel_id'];
	}
	$args['attributes'][':aria-expanded'] = sprintf( '%s.toString()', $args['panel_var'] );

	// Add descriptive aria-label.
	if ( ! empty( $args['method_name'] ) ) {
		$args['attributes']['aria-label'] = sprintf( __( 'Configure %s settings', 'affiliate-wp' ), $args['method_name'] );
	}

	// Add click handler if not already set.
	if ( ! isset( $args['attributes']['@click'] ) && ! isset( $args['attributes']['@click.stop'] ) ) {
		$args['attributes']['@click.stop'] = sprintf( '%s = !%s', $args['panel_var'], $args['panel_var'] );
	}

	// Add keyboard handlers for Enter and Space keys.
	if ( ! isset( $args['attributes']['@keydown.enter.prevent'] ) ) {
		$args['attributes']['@keydown.enter.prevent'] = sprintf( '%s = !%s', $args['panel_var'], $args['panel_var'] );
	}
	if ( ! isset( $args['attributes']['@keydown.space.prevent'] ) ) {
		$args['attributes']['@keydown.space.prevent'] = sprintf( '%s = !%s', $args['panel_var'], $args['panel_var'] );
	}

	// Remove the panel-specific args before passing to render_button.
	unset( $args['panel_var'] );
	unset( $args['panel_id'] );
	unset( $args['method_name'] );

	return affwp_render_button( $args );
}

/**
 * Display a configure button with animated chevron
 *
 * @since 2.29.0
 *
 * @param array $args Button arguments. See affwp_render_configure_button() for details.
 */
function affwp_configure_button( $args = [] ) {
	echo affwp_render_configure_button( $args );
}

/**
 * Render a toggle switch component
 *
 * @since 2.29.0
 *
 * @param array $args {
 *     Toggle arguments.
 *
 *     @type string $name         Input name attribute. Required.
 *     @type string $label        Accessible label for the toggle. Required.
 *     @type string $id           Input ID attribute. Optional.
 *     @type string $value        Input value attribute. Default '1'.
 *     @type bool   $checked      Whether the toggle is checked. Default false.
 *     @type bool   $disabled     Whether the toggle is disabled. Default false.
 *     @type string $size         Toggle size (sm, md, lg). Default 'md'.
 *     @type string $color        Active color (blue, green, purple, red). Default 'blue'.
 *     @type array  $attributes   Additional HTML attributes (supports AlpineJS directives). Optional.
 *     @type string $alpine_model AlpineJS model binding (x-model). Optional.
 *     @type string $class        Additional CSS classes for the wrapper. Optional.
 * }
 * @return string The rendered toggle HTML.
 */
function affwp_render_toggle( $args = [] ) {
	return affwp_ui_components()->render( 'toggle', $args );
}

/**
 * Display a toggle switch component
 *
 * @since 2.29.0
 *
 * @param array $args Toggle arguments. See affwp_render_toggle() for details.
 */
function affwp_toggle( $args = [] ) {
	echo affwp_render_toggle( $args );
}

/**
 * Render a native-styled select component.
 *
 * Tailwind-styled select that matches the AffiliateWP admin design system and opts out
 * of the site-wide Select2 auto-init via the `affwp-ignore-select2` class.
 *
 * @since 2.33.0
 *
 * @param array $args {
 *     Select arguments.
 *
 *     @type string $name          Input name attribute. Required.
 *     @type array  $options       Map of `value => label` pairs. Required.
 *     @type string $id            Input ID attribute. Derived from name when empty.
 *     @type string $selected      Currently selected value.
 *     @type string $size          'sm' or 'md'. Default 'md'.
 *     @type string $max_width     Tailwind max-width utility for the wrapper. Default 'max-w-sm'.
 *                                 Pass '' for no max-width.
 *     @type string $alpine_model  AlpineJS x-model binding. Optional.
 *     @type string $class         Additional classes on the <select> element. Optional.
 *     @type string $wrapper_class Additional classes on the grid wrapper. Optional.
 *     @type array  $attributes    Additional HTML attributes (supports Alpine directives). Optional.
 *     @type bool   $disabled      Whether the select is disabled. Default false.
 * }
 * @return string The rendered select HTML.
 */
function affwp_render_select( $args = array() ) {
	return affwp_ui_components()->render( 'select', $args );
}

/**
 * Display a select component.
 *
 * @since 2.33.0
 *
 * @param array $args Select arguments. See affwp_render_select() for details.
 */
function affwp_select( $args = array() ) {
	echo affwp_render_select( $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- affwp_render_select() escapes attributes via esc_attr() and option labels via esc_html(); wp_kses would strip Alpine x-model bindings.
}

/**
 * Render an <input> element styled for the AffiliateWP admin Tailwind UI.
 *
 * @since 2.33.0
 *
 * @param array $args {
 *     Input arguments.
 *
 *     @type string $name         Input name. Required.
 *     @type string $type         Default 'text'. Supports text, number, email, url, password, tel, search.
 *     @type string $value        Current value.
 *     @type string $id           ID attribute. Derived from name when empty.
 *     @type string $placeholder  Placeholder text.
 *     @type string $size         'sm' or 'md'. Default 'md'.
 *     @type string $width        'full' | 'narrow' | 'auto'. Default 'full'.
 *     @type bool   $mono         Render with a monospace font.
 *     @type bool   $secret       Credential-field hints (autocomplete, lpignore, 1p-ignore).
 *     @type string $autocomplete Explicit autocomplete value.
 *     @type string $alpine_model Alpine x-model binding.
 *     @type bool   $disabled     Whether the input is disabled.
 *     @type bool   $required     Whether the input is required.
 *     @type mixed  $min          Minimum value (number inputs).
 *     @type mixed  $max          Maximum value (number inputs).
 *     @type mixed  $step         Step value (number inputs).
 *     @type string $class        Extra classes on the <input>.
 *     @type array  $attributes   Additional attributes (supports Alpine directives).
 * }
 * @return string
 */
function affwp_render_input( $args = array() ) {
	return affwp_ui_components()->render( 'input', $args );
}

/**
 * Display an input component.
 *
 * @since 2.33.0
 *
 * @param array $args Input arguments. See affwp_render_input() for details.
 */
function affwp_input( $args = array() ) {
	echo affwp_render_input( $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- affwp_render_input() escapes every attribute via esc_attr(); wp_kses would strip Alpine x-model and event bindings.
}

/**
 * Render a <textarea> element styled for the AffiliateWP admin Tailwind UI.
 *
 * Accepts the same args as affwp_render_input(), plus `rows` (default 4).
 *
 * @since 2.33.0
 *
 * @param array $args Textarea arguments.
 * @return string
 */
function affwp_render_textarea( $args = array() ) {
	return affwp_ui_components()->render( 'textarea', $args );
}

/**
 * Display a textarea component.
 *
 * @since 2.33.0
 *
 * @param array $args Textarea arguments. See affwp_render_textarea() for details.
 */
function affwp_textarea( $args = array() ) {
	echo affwp_render_textarea( $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- affwp_render_textarea() escapes attributes via esc_attr() and body via esc_textarea(); wp_kses would strip Alpine bindings.
}

/**
 * Render a simple toggle switch
 *
 * @since 2.29.0
 *
 * @param string $name    Input name attribute.
 * @param string $label   Accessible label for the toggle.
 * @param bool   $checked Whether the toggle is checked.
 * @param array  $args    Additional toggle arguments.
 * @return string The rendered toggle HTML.
 */
function affwp_render_simple_toggle( $name, $label, $checked = false, $args = [] ) {
	$args['name']    = $name;
	$args['label']   = $label;
	$args['checked'] = $checked;
	return affwp_render_toggle( $args );
}

/**
 * Display a simple toggle switch
 *
 * @since 2.29.0
 *
 * @param string $name    Input name attribute.
 * @param string $label   Accessible label for the toggle.
 * @param bool   $checked Whether the toggle is checked.
 * @param array  $args    Additional toggle arguments.
 */
function affwp_simple_toggle( $name, $label, $checked = false, $args = [] ) {
	echo affwp_render_simple_toggle( $name, $label, $checked, $args );
}

/**
 * Render an accordion component
 *
 * @since 2.29.0
 *
 * @param array $args {
 *     Accordion arguments.
 *
 *     @type string $id            Unique ID for the accordion. Required for Alpine state.
 *     @type string $header        Header content HTML. Required.
 *     @type string $content       Body content HTML. Required.
 *     @type bool   $default_open  Whether accordion is open by default. Default false.
 *     @type bool   $clickable     Whether header is clickable to toggle. Default true.
 *     @type string $alpine_var    Custom Alpine variable name. Optional.
 *     @type string $class         Additional CSS classes for wrapper. Optional.
 *     @type string $header_class  Additional CSS classes for header. Optional.
 *     @type string $content_class Additional CSS classes for content. Optional.
 *     @type array  $attributes    Additional wrapper attributes. Optional.
 *     @type bool   $nested        Whether this is a nested accordion. Default false.
 *     @type string $persist_key   Key for persisting state in localStorage. Optional.
 * }
 * @return string The rendered accordion HTML.
 */
function affwp_render_accordion( $args = [] ) {
	$defaults = [
		'id'            => '',
		'header'        => '',
		'content'       => '',
		'default_open'  => false,
		'clickable'     => true,
		'alpine_var'    => '',
		'class'         => '',
		'header_class'  => '',
		'content_class' => '',
		'attributes'    => [],
		'nested'        => false,
		'persist_key'   => '',
	];

	$args = wp_parse_args( $args, $defaults );

	// Generate unique Alpine variable if not provided
	if ( empty( $args['alpine_var'] ) ) {
		$args['alpine_var'] = 'accordion_' . ( ! empty( $args['id'] ) ? $args['id'] : uniqid() );
	}

	// Build Alpine data attribute
	if ( ! empty( $args['persist_key'] ) ) {
		// Use persistence if persist_key is provided
		$alpine_data = sprintf(
			'{ %s: $persist(%s).as(\'%s\') }',
			esc_attr( $args['alpine_var'] ),
			$args['default_open'] ? 'true' : 'false',
			esc_attr( $args['persist_key'] )
		);
	} else {
		// Standard non-persistent data
		$alpine_data = sprintf(
			'{ %s: %s }',
			esc_attr( $args['alpine_var'] ),
			$args['default_open'] ? 'true' : 'false'
		);
	}

	// Build wrapper attributes
	$wrapper_attrs           = $args['attributes'];
	$wrapper_attrs['x-data'] = $alpine_data;
	if ( ! empty( $args['class'] ) ) {
		$wrapper_attrs['class'] = $args['class'];
	}

	// Build header attributes
	$header_attrs = [];
	if ( $args['clickable'] ) {
		$header_attrs['@click'] = sprintf( '%s = !%s', esc_attr( $args['alpine_var'] ), esc_attr( $args['alpine_var'] ) );
		$header_attrs['class']  = 'cursor-pointer ' . $args['header_class'];
	} else {
		$header_attrs['class'] = $args['header_class'];
	}

	// Build content attributes
	$content_attrs = [
		'x-show'                    => esc_attr( $args['alpine_var'] ),
		'x-collapse.duration.300ms' => '',
		'x-cloak'                   => '',
	];
	if ( ! empty( $args['content_class'] ) ) {
		$content_attrs['class'] = $args['content_class'];
	}

	// Build the HTML
	$html = '<div';
	foreach ( $wrapper_attrs as $attr => $value ) {
		$html .= sprintf( ' %s="%s"', $attr, esc_attr( $value ) );
	}
	$html .= '>';

	// Header
	$html .= '<div';
	foreach ( $header_attrs as $attr => $value ) {
		$html .= sprintf( ' %s="%s"', $attr, esc_attr( $value ) );
	}
	$html .= '>';
	$html .= $args['header'];
	$html .= '</div>';

	// Content
	$html .= '<div';
	foreach ( $content_attrs as $attr => $value ) {
		if ( $value === '' ) {
			$html .= ' ' . $attr;
		} else {
			$html .= sprintf( ' %s="%s"', $attr, esc_attr( $value ) );
		}
	}
	$html .= '>';
	$html .= $args['content'];
	$html .= '</div>';

	$html .= '</div>';

	return $html;
}

/**
 * Display an accordion component
 *
 * @since 2.29.0
 *
 * @param array $args Accordion arguments. See affwp_render_accordion() for details.
 */
function affwp_accordion( $args = [] ) {
	echo affwp_render_accordion( $args );
}

/**
 * Render a modal component
 *
 * @since 2.29.0
 *
 * @param array $args {
 *     Modal arguments.
 *
 *     @type string $id                Unique ID for the modal. Required.
 *     @type string $title             Modal title. Required.
 *     @type string $content           Modal body content (HTML). Optional.
 *     @type string $size              Modal size (sm, md, lg, xl, full). Default 'md'.
 *     @type string $variant           Modal variant (default, warning, danger, success, info). Default 'default'.
 *     @type bool   $show_close        Whether to show close button. Default true.
 *     @type bool   $close_on_backdrop Whether clicking backdrop closes modal. Default true.
 *     @type bool   $close_on_escape   Whether ESC key closes modal. Default true.
 *     @type array  $footer_actions    Array of button configurations for footer. Optional.
 *     @type string $alpine_var        Alpine variable name for modal state. Default 'modalOpen'.
 *     @type bool   $persist_state     Whether to persist modal state. Default false.
 *     @type array  $icon              Icon configuration array. Optional.
 *     @type string $custom_header     Custom header HTML (overrides title and icon). Optional.
 *     @type string $custom_footer     Custom footer HTML (overrides footer_actions). Optional.
 *     @type string $class             Additional CSS classes for modal. Optional.
 *     @type array  $attributes        Additional HTML attributes. Optional.
 * }
 * @return string The rendered modal HTML.
 */
function affwp_render_modal( $args = [] ) {
	return affwp_ui_components()->render( 'modal', $args );
}

/**
 * Display a modal component
 *
 * @since 2.29.0
 *
 * @param array $args Modal arguments. See affwp_render_modal() for details.
 */
function affwp_modal( $args = [] ) {
	echo affwp_render_modal( $args );
}

/**
 * Build the Alpine `x-data` expression that binds a modal to its entry in the
 * shared modal store.
 *
 * Exposes `modalData` (the store registry entry's data, or `{}`) and `isLoading`
 * to everything inside the modal's Alpine scope. Pass the result as the modal's
 * `attributes['x-data']`.
 *
 * @since 2.34.0
 *
 * @param string $id Modal id (its `$store.modals.registry` key).
 * @return string Alpine x-data expression.
 */
function affwp_modal_store_data( $id ) {
	return sprintf(
		'{ get modalData() { const m = $store.modals.registry[%s]; return ( m && m.data ) || {}; }, get isLoading() { return this.modalData.isLoading || false; } }',
		wp_json_encode( (string) $id )
	);
}

/**
 * Render a confirmation modal
 *
 * @since 2.29.0
 *
 * @param string $id      Modal ID.
 * @param string $title   Modal title.
 * @param string $message Confirmation message.
 * @param array  $args    Additional modal arguments.
 * @return string The rendered modal HTML.
 */
function affwp_render_confirmation_modal( $id, $title, $message, $args = [] ) {
	$defaults = [
		'id'             => $id,
		'title'          => $title,
		'content'        => '<p class="text-sm text-gray-600">' . esc_html( $message ) . '</p>',
		'variant'        => 'warning',
		'icon'           => 'exclamation-triangle',
		'footer_actions' => [
			[
				'text'    => __( 'Cancel', 'affiliate-wp' ),
				'variant' => 'secondary',
			],
			[
				'text'    => __( 'Confirm', 'affiliate-wp' ),
				'variant' => 'primary',
			],
		],
	];

	$args = wp_parse_args( $args, $defaults );
	return affwp_render_modal( $args );
}

/**
 * Display a confirmation modal
 *
 * @since 2.29.0
 *
 * @param string $id      Modal ID.
 * @param string $title   Modal title.
 * @param string $message Confirmation message.
 * @param array  $args    Additional modal arguments.
 */
function affwp_confirmation_modal( $id, $title, $message, $args = [] ) {
	echo affwp_render_confirmation_modal( $id, $title, $message, $args );
}

/**
 * Render an alert modal
 *
 * @since 2.29.0
 *
 * @param string $id      Modal ID.
 * @param string $title   Modal title.
 * @param string $message Alert message.
 * @param string $type    Alert type (info, success, warning, danger). Default 'info'.
 * @param array  $args    Additional modal arguments.
 * @return string The rendered modal HTML.
 */
function affwp_render_alert_modal( $id, $title, $message, $type = 'info', $args = [] ) {
	$defaults = [
		'id'             => $id,
		'title'          => $title,
		'content'        => '<p class="text-sm text-gray-600">' . esc_html( $message ) . '</p>',
		'variant'        => $type,
		'footer_actions' => [
			[
				'text'    => __( 'OK', 'affiliate-wp' ),
				'variant' => 'primary',
			],
		],
	];

	// Set appropriate icon based on type.
	$icons = [
		'info'    => 'information-circle',
		'success' => 'check-circle',
		'warning' => 'exclamation-triangle',
		'danger'  => 'x-circle',
	];

	if ( isset( $icons[ $type ] ) ) {
		$defaults['icon'] = $icons[ $type ];
	}

	$args = wp_parse_args( $args, $defaults );
	return affwp_render_modal( $args );
}

/**
 * Display an alert modal
 *
 * @since 2.29.0
 *
 * @param string $id      Modal ID.
 * @param string $title   Modal title.
 * @param string $message Alert message.
 * @param string $type    Alert type (info, success, warning, danger). Default 'info'.
 * @param array  $args    Additional modal arguments.
 */
function affwp_alert_modal( $id, $title, $message, $type = 'info', $args = [] ) {
	echo affwp_render_alert_modal( $id, $title, $message, $type, $args );
}

/**
 * Render a drawer component
 *
 * Returns the drawer HTML string. The drawer is a slide-over panel
 * anchored to the right side of the viewport with a backdrop overlay.
 *
 * @since 2.33.0
 *
 * @param array $args {
 *     Drawer arguments.
 *
 *     @type string   $id               Unique ID for the drawer. Required.
 *     @type string   $alpine_store     Alpine.js store name. Required.
 *     @type string   $width            Drawer width (md, lg, xl, 2xl). Default 'xl'.
 *     @type string   $header_style     Header style preset (gradient, flat). Default 'gradient'.
 *     @type callable $content_callback PHP callable for body content. Optional.
 *     @type array    $content_args     Arguments passed to content callback. Default [].
 *     @type callable $summary_callback PHP callable for summary section. Optional.
 *     @type array    $summary_args     Arguments passed to summary callback. Default [].
 *     @type callable $footer_callback  PHP callable for custom footer. Default renders standard footer.
 *     @type array    $footer_args      Arguments passed to footer callback. Default [].
 *     @type string   $save_text        Save button text for create mode. Default 'Save'.
 *     @type string   $update_text      Save button text for edit mode. Default 'Done'.
 *     @type string   $saving_text      Save button text while saving. Default 'Saving...'.
 *     @type string   $cancel_text      Cancel button text. Default 'Cancel'.
 * }
 * @return string The rendered drawer HTML.
 */
function affwp_render_drawer( $args = [] ) {
	return affwp_ui_components()->render( 'drawer', $args );
}

/**
 * Display a drawer component
 *
 * Echoes the drawer HTML directly. See affwp_render_drawer() for parameter details.
 *
 * @since 2.33.0
 *
 * @param array $args Drawer arguments. See affwp_render_drawer() for details.
 */
function affwp_drawer( $args = [] ) {
	echo affwp_render_drawer( $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- affwp_render_drawer() escapes attributes and Alpine store refs via esc_attr(); wp_kses would strip x-show/@click bindings that drive the drawer.
}

/**
 * Generate Alpine.js dirty state tracking methods for drawer stores.
 *
 * Returns a 'methods' JS string to spread into an Alpine store. Dirty tracking
 * is event-driven: the drawer trait adds @input and @change listeners on the
 * content area that call _markDirty(). This automatically covers every native
 * form element (text inputs, selects, radios, checkboxes, toggles) without
 * needing to list field names.
 *
 * For non-native interactions (e.g. affiliate picker add/remove buttons),
 * call _markDirty() directly from the action handler.
 *
 * The consuming store only needs to spread the methods and declare _dirty: false.
 * No field lists required.
 *
 * Usage in an Alpine store:
 *
 *     Alpine.store('myDrawer', {
 *         _dirty: false,
 *         <?php echo affwp_drawer_dirty_tracking_alpine()['methods']; ?>
 *         ...other properties...
 *     });
 *
 * @since 2.33.0
 *
 * @return array {
 *     @type string $methods JS methods string to spread into an Alpine store.
 * }
 */
function affwp_drawer_dirty_tracking_alpine() {

	$confirm_message = esc_js( __( 'You have unsaved changes. Discard them?', 'affiliate-wp' ) );

	$methods = <<<JS
get isDirty() {
	return this._dirty;
},

_markDirty() {
	this._dirty = true;
},

confirmClose() {
	if (!this.isDirty) {
		this.close();
		return;
	}
	if (window.confirm('{$confirm_message}')) {
		this.close();
	}
},
JS;

	return [
		'methods' => $methods,
	];
}

/**
 * Render the toast notification container.
 *
 * Should be called once per page. Subsequent calls are no-ops.
 * Trigger toasts via JavaScript:
 *
 *     window.dispatchEvent(new CustomEvent('affwp-toast', {
 *         detail: { message: 'Done!', type: 'success' }
 *     }));
 *
 * @since 2.33.0
 *
 * @return string The rendered toast HTML.
 */
function affwp_render_toast() {
	return affwp_ui_components()->render( 'toast' );
}

/**
 * Display the toast notification container.
 *
 * @since 2.33.0
 */
function affwp_toast() {
	echo affwp_render_toast(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- affwp_render_toast() emits hardcoded markup plus esc_html_e()/esc_attr() escaped pieces; wp_kses would strip Alpine x-show/x-text/@click bindings.
}

/**
 * Render a card component
 *
 * @since 2.29.0
 *
 * @param array $args Card arguments. See trait-cards.php for full documentation.
 * @return void
 */
function affwp_card( $args = [] ) {
	echo affwp_get_card( $args );
}

/**
 * Get a card component HTML
 *
 * @since 2.29.0
 *
 * @param array $args Card arguments. See trait-cards.php for full documentation.
 * @return string The card HTML.
 */
function affwp_get_card( $args = [] ) {
	return affwp_ui()->render( 'card', $args );
}

/**
 * Render a card component
 *
 * @since 2.29.0
 *
 * @param array $args Card arguments. See trait-cards.php for full documentation.
 * @return void
 */
function affwp_render_card( $args = [] ) {
	echo affwp_get_card( $args );
}

/**
 * Render a card header
 *
 * @since 2.29.0
 *
 * @param string|array $header Header content or configuration.
 * @param array        $args   Additional card arguments.
 * @return void
 */
function affwp_card_header( $header, $args = [] ) {
	echo affwp_get_card_header( $header, $args );
}

/**
 * Get card header HTML
 *
 * @since 2.29.0
 *
 * @param string|array $header Header content or configuration.
 * @param array        $args   Additional card arguments.
 * @return string The header HTML.
 */
function affwp_get_card_header( $header, $args = [] ) {
	// Create minimal card args with just the header.
	$card_args = wp_parse_args(
		$args,
		[
			'header' => $header,
			'body'   => ' ', // Non-empty to trigger structured content.
		]
	);

	// Render card and extract just the header.
	$card_html = affwp_get_card( $card_args );

	// Extract header HTML (between first <div and first border-b closing div).
	if ( preg_match( '/<div[^>]*class="[^"]*px-6 py-4[^"]*"[^>]*>.*?<\/div>/s', $card_html, $matches ) ) {
		return $matches[0];
	}

	return '';
}

/**
 * Render a card body
 *
 * @since 2.29.0
 *
 * @param string $body Body content.
 * @param array  $args Additional card arguments.
 * @return void
 */
function affwp_card_body( $body, $args = [] ) {
	echo affwp_get_card_body( $body, $args );
}

/**
 * Get card body HTML
 *
 * @since 2.29.0
 *
 * @param string $body Body content.
 * @param array  $args Additional card arguments.
 * @return string The body HTML.
 */
function affwp_get_card_body( $body, $args = [] ) {
	$body_classes = [ 'px-6 py-4' ];

	// Add text color for dark variant.
	if ( isset( $args['variant'] ) && $args['variant'] === 'dark' ) {
		$body_classes[] = 'text-gray-300';
	}

	return sprintf(
		'<div class="%s">%s</div>',
		esc_attr( implode( ' ', $body_classes ) ),
		$body
	);
}

/**
 * Render a card footer
 *
 * @since 2.29.0
 *
 * @param string|array $footer Footer content or configuration.
 * @param array        $args   Additional card arguments.
 * @return void
 */
function affwp_card_footer( $footer, $args = [] ) {
	echo affwp_get_card_footer( $footer, $args );
}

/**
 * Get card footer HTML
 *
 * @since 2.29.0
 *
 * @param string|array $footer Footer content or configuration.
 * @param array        $args   Additional card arguments.
 * @return string The footer HTML.
 */
function affwp_get_card_footer( $footer, $args = [] ) {
	// Create minimal card args with just the footer.
	$card_args = wp_parse_args(
		$args,
		[
			'body'   => ' ', // Non-empty to trigger structured content.
			'footer' => $footer,
		]
	);

	// Render card and extract just the footer.
	$card_html = affwp_get_card( $card_args );

	// Extract footer HTML (last div with border-t class).
	if ( preg_match( '/<div[^>]*class="[^"]*border-t[^"]*"[^>]*>.*?<\/div>$/s', $card_html, $matches ) ) {
		return $matches[0];
	}

	return '';
}

/**
 * Render a list-page empty state.
 *
 * Shape matches the `affwp_list_page_empty_state()` helper shipping in
 * PR #5752 so the two branches merge without a rename. No background /
 * border — the parent controls the container. Bigger title (text-2xl),
 * roomier description (text-base, max-w-lg), bolder button (px-7 py-3).
 *
 * Use on list-table pages *before* rendering the list when the underlying
 * dataset is empty for a genuine first-run reason (no records have ever
 * been created), not when filters or search happen to produce zero rows.
 *
 * @since 2.33.0
 *
 * @param array $args {
 *     @type string          $title             Headline (required).
 *     @type string          $description       Supporting copy below the title.
 *     @type string          $icon              Optional SVG markup (rendered as-is,
 *                                               scaled to size-12 in a muted colour).
 *     @type string|callable $content           Replaces the icon + description block
 *                                               entirely. Callable is invoked; string
 *                                               rendered verbatim. Use this when the
 *                                               default text layout isn't enough.
 *     @type string          $button_text       Primary CTA label.
 *     @type string          $button_url        CTA href. Omit to render a `<button>`.
 *     @type array           $button_attributes Extra attributes on the CTA element.
 *     @type string          $button_subtitle   Small muted text below the CTA.
 *     @type string          $footer            Optional small HTML below everything.
 * }
 * @return void
 */
function affwp_list_page_empty_state( $args = array() ) {

	$args = wp_parse_args(
		$args,
		array(
			'title'             => '',
			'title_class'       => 'text-2xl font-semibold text-gray-900 m-0 tracking-tight',
			'description'       => '',
			'icon'              => '',
			'content'           => '',
			'button_text'       => '',
			'button_url'        => '',
			'button_attributes' => array(),
			'button_badge'      => '', // Pre-rendered badge HTML appended after the label (e.g. affwp_get_pro_badge() as an inline gating marker).
			'button_subtitle'   => '',
			'footer'            => '',
		)
	);

	$has_content = ! empty( $args['content'] );

	?>
	<div class="affwp-ui">
		<div class="text-center pt-10 pb-6 px-6">

			<?php if ( ! $has_content && ! empty( $args['icon'] ) ) : ?>
				<div class="flex justify-center mb-4 text-gray-400 [&>svg]:size-12">
					<?php echo $args['icon']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw SVG markup. ?>
				</div>
			<?php endif; ?>

			<h3 class="<?php echo esc_attr( $args['title_class'] ); ?>">
				<?php echo esc_html( $args['title'] ); ?>
			</h3>

			<?php if ( ! empty( $args['description'] ) ) : ?>
				<p class="mt-2 mb-0 text-base text-gray-500 max-w-lg mx-auto text-pretty">
					<?php echo esc_html( $args['description'] ); ?>
				</p>
			<?php endif; ?>

			<?php
			if ( $has_content ) {
				if ( is_callable( $args['content'] ) ) {
					call_user_func( $args['content'] );
				} else {
					echo $args['content']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw HTML slot.
				}
			}
			?>

			<?php
			if ( ! empty( $args['button_text'] ) ) :
				$btn_classes = 'inline-flex items-center justify-center rounded-md bg-affwp-brand-500 px-7 py-3 text-[15px] font-semibold text-white no-underline shadow-sm hover:bg-affwp-brand-600 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-affwp-brand-500 cursor-pointer border-0';

				$btn_attrs = '';
				foreach ( $args['button_attributes'] as $attr_key => $attr_val ) {
					$btn_attrs .= ' ' . $attr_key . '="' . esc_attr( $attr_val ) . '"';
				}
			?>
				<div class="flex flex-col items-center gap-y-1.5 mt-6">
					<?php if ( ! empty( $args['button_url'] ) ) : ?>
						<a href="<?php echo esc_url( $args['button_url'] ); ?>" class="<?php echo esc_attr( $btn_classes ); ?>"<?php echo $btn_attrs; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- attributes pre-escaped per-key above. ?>>
							<?php echo esc_html( $args['button_text'] ); ?>
							<?php echo $args['button_badge']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- badge helper returns trusted HTML. ?>
						</a>
					<?php else : ?>
						<button type="button" class="<?php echo esc_attr( $btn_classes ); ?>"<?php echo $btn_attrs; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- as above. ?>>
							<?php echo esc_html( $args['button_text'] ); ?>
							<?php echo $args['button_badge']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- badge helper returns trusted HTML. ?>
						</button>
					<?php endif; ?>

					<?php if ( ! empty( $args['button_subtitle'] ) ) : ?>
						<?php $subtitle_class = ! empty( $args['button_subtitle_class'] ) ? $args['button_subtitle_class'] : 'text-[13px] text-gray-500'; // default: muted fine print; callers can emphasize a value line. ?>
						<span class="<?php echo esc_attr( $subtitle_class ); ?>"><?php echo esc_html( $args['button_subtitle'] ); ?></span>
					<?php endif; ?>
				</div>
			<?php endif; ?>

			<?php if ( ! empty( $args['footer'] ) ) : ?>
				<p class="mt-5 mb-0 text-[13px]">
					<?php echo $args['footer']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- raw HTML footer. ?>
				</p>
			<?php endif; ?>

		</div>
	</div>
	<?php
}

/**
 * Render an inline admin notice (tip / warning callout).
 *
 * @since 2.33.0
 *
 * @param array $args See \AffiliateWP\Utils\Traits\Notices::render_notice() for the full
 *                     argument list (variant, heading, body, actions, dismissable, etc.).
 * @return string Notice HTML.
 */
function affwp_render_notice( $args = [] ) {
	return affwp_ui_components()->render( 'notice', $args );
}

/**
 * Echo an inline admin notice.
 *
 * @since 2.33.0
 *
 * @param array $args See affwp_render_notice() for arguments.
 */
function affwp_notice( $args = [] ) {
	echo affwp_render_notice( $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML composed/escaped inside trait.
}

/**
 * Return a label/value property list, drawer-style.
 *
 * @since 2.33.0
 *
 * @param array $properties Associative array of label => value, or array of
 *                          [ 'label' => string, 'value' => string|array ]. Values are
 *                          esc_html'd; pass [ 'html' => '...' ] for pre-sanitized HTML.
 * @param array $args       Optional. See trait-data-display.php render_property_list().
 * @return string Rendered HTML.
 */
function affwp_get_property_list( $properties, $args = [] ) {
	$args['properties'] = $properties;
	return affwp_ui_components()->render( 'property_list', $args );
}

/**
 * Echo a label/value property list, drawer-style.
 *
 * @since 2.33.0
 *
 * @param array $properties See affwp_get_property_list().
 * @param array $args       Optional.
 */
function affwp_property_list( $properties, $args = [] ) {
	echo affwp_get_property_list( $properties, $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML composed/escaped inside trait.
}

/**
 * Return a multi-column data table.
 *
 * @since 2.33.0
 *
 * @param array $columns Array of column defs (key, label, optional class).
 * @param array $rows    Array of row data, keyed by column 'key'.
 * @param array $args    Optional. See trait-data-display.php render_data_table().
 * @return string Rendered HTML.
 */
function affwp_get_data_table( $columns, $rows, $args = [] ) {
	$args['columns'] = $columns;
	$args['rows']    = $rows;
	return affwp_ui_components()->render( 'data_table', $args );
}

/**
 * Echo a multi-column data table.
 *
 * @since 2.33.0
 *
 * @param array $columns See affwp_get_data_table().
 * @param array $rows    See affwp_get_data_table().
 * @param array $args    Optional.
 */
function affwp_data_table( $columns, $rows, $args = [] ) {
	echo affwp_get_data_table( $columns, $rows, $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- HTML composed/escaped inside trait.
}

/**
 * Resolve the display label for an affiliate in the picker.
 *
 * Single source of truth for how an affiliate is labelled in the picker's
 * search results and its seeded chips, so both always match. Falls back to the
 * user login, then the affiliate ID, when no display name is set.
 *
 * @since 2.35.0
 *
 * @param int $affiliate_id Affiliate ID.
 * @return string Display label, e.g. "Jane Doe (#12)", "janedoe (#12)", or "Affiliate #12".
 */
function affwp_affiliate_picker_label( $affiliate_id ) {
	$affiliate_id = absint( $affiliate_id );
	$name         = affwp_get_affiliate_name( $affiliate_id );

	if ( '' !== (string) $name ) {
		return sprintf( '%s (#%d)', $name, $affiliate_id );
	}

	$affiliate = affwp_get_affiliate( $affiliate_id );
	$user      = $affiliate ? get_userdata( $affiliate->user_id ) : false;

	if ( $user && $user->user_login ) {
		return sprintf( '%s (#%d)', $user->user_login, $affiliate_id );
	}

	return sprintf( __( 'Affiliate #%d', 'affiliate-wp' ), $affiliate_id );
}

/**
 * Render an affiliate picker component.
 *
 * Outputs a searchable, tag-based affiliate multi-select that works with any
 * Alpine store. The consuming store must include the state and methods returned
 * by {@see affwp_affiliate_picker_alpine()}.
 *
 * @since 2.33.0
 *
 * @param array $args {
 *     Configuration arguments.
 *
 *     @type string $store          Alpine store path, e.g. '$store.groupDrawer'. Required.
 *     @type string $id             Unique HTML id prefix for the input. Default 'affiliate-picker'.
 *     @type string $placeholder    Search input placeholder text. Default 'Search affiliates...'.
 *     @type string $label          Optional label rendered above the picker.
 *     @type string $description    Optional help text rendered below the picker.
 * }
 */
function affwp_affiliate_picker( $args = [] ) {
	echo affwp_render_affiliate_picker( $args );
}

/**
 * Get affiliate picker component HTML.
 *
 * @since 2.33.0
 *
 * @param array $args See {@see affwp_affiliate_picker()} for accepted arguments.
 * @return string The picker HTML.
 */
function affwp_render_affiliate_picker( $args = [] ) {

	$args = wp_parse_args( $args, [
		'store'              => '',
		'id'                 => 'affiliate-picker',
		'placeholder'        => __( 'Search affiliates...', 'affiliate-wp' ),
		'label'              => '',
		'description'        => '',
		'show_group'         => false,
		'include_groups'     => false,
		/*
		 * Wrap freely up to this many chips, then collapse the overflow behind "+N more"
		 * (click expands inline). A falsy value (0, false, null) disables truncation so chips wrap freely.
		 */
		'truncate_chips_at'  => 5,
		/*
		 * When true, chips render inside the input as a combobox (one bordered container,
		 * input as the trailing entry slot). When false (default), chips render in a row above the input.
		 */
		'chips_inside_input' => false,
		// 'sm' (text-sm py-1.5, matches affwp_input/select sm) or 'md' (text-base py-2). Default 'sm'.
		'size'               => 'sm',
		/*
		 * Single-select mode. The store is expected to keep at most one entry in
		 * selectedAffiliates (e.g. addAffiliate() replaces rather than appends).
		 * The UI then drops its multi-select chrome: selecting closes the dropdown,
		 * and the search input is hidden while a selection exists (remove the chip
		 * to change it). Default false keeps the multi-select behavior intact.
		 */
		'single'             => false,
		/*
		 * Field-level validation. An Alpine expression that evaluates to the error
		 * message string (falsy = no error). When truthy the input gets a red border
		 * + aria-invalid and the message renders below it — instead of a drawer-top
		 * banner. Empty by default (no error chrome). Standalone mode only.
		 */
		'error'              => '',
	] );

	$truncate_chips_at  = (int) $args['truncate_chips_at'];
	$truncates_chips    = $truncate_chips_at > 0;
	$chips_inside_input = (bool) $args['chips_inside_input'];
	$single             = (bool) $args['single'];
	$error              = (string) $args['error'];

	// In single-select mode, selecting an affiliate also closes the dropdown.
	$on_pick_extra = $single ? '; showDropdown = false' : '';

	$size_tokens = [
		'sm' => [
			'combobox_min_h' => 'min-h-9',
			'combobox_input' => 'py-0.5 text-sm',
			'standalone_in'  => 'py-1.5 text-sm',
			'chip'           => 'py-0.5 pl-2 pr-0.5 text-xs',
			'chip_icon'      => 'size-3.5',
			'more_button'    => 'px-2 py-1 text-xs',
		],
		'md' => [
			'combobox_min_h' => 'min-h-10',
			'combobox_input' => 'py-1 text-base',
			'standalone_in'  => 'py-2 text-base',
			'chip'           => 'py-1 pl-2.5 pr-1 text-sm',
			'chip_icon'      => 'size-4',
			'more_button'    => 'px-2.5 py-1 text-sm',
		],
	];
	$size = $size_tokens[ $args['size'] ] ?? $size_tokens['sm'];

	if ( empty( $args['store'] ) ) {
		return '';
	}

	$store = $args['store'];
	$id    = esc_attr( $args['id'] );

	// Shared dropdown markup for both render paths, scoped to whatever Alpine x-data parent provides `showDropdown`.
	$render_dropdown = static function () use ( $store, $args, $on_pick_extra, $id ) {
		?>
		<!-- Dropdown -->
		<div
			x-show="showDropdown && (<?php echo esc_attr( "{$store}.searchResults.length > 0" ); ?> || <?php echo esc_attr( "{$store}.searchLoading" ); ?>)"
			x-cloak
			class="absolute z-10 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg overflow-hidden"
		>
			<!-- Header -->
			<div class="px-3 py-1.5 bg-gray-50 border-b border-gray-100 flex items-center justify-between">
				<div>
					<span x-show="<?php echo esc_attr( "{$store}.searchLoading" ); ?>" class="text-xs font-medium text-gray-400"><?php esc_html_e( 'Searching...', 'affiliate-wp' ); ?></span>
					<span x-show="<?php echo esc_attr( "!{$store}.searchLoading && {$store}.affiliateSearch.length === 0" ); ?>" class="text-xs font-medium text-gray-500"><?php esc_html_e( 'Recent affiliates', 'affiliate-wp' ); ?></span>
					<span x-show="<?php echo esc_attr( "!{$store}.searchLoading && {$store}.affiliateSearch.length > 0" ); ?>" x-cloak class="text-xs font-medium text-gray-500">
						<span x-text="<?php echo esc_attr( "{$store}.searchResults.length" ); ?>"></span> <?php esc_html_e( 'results', 'affiliate-wp' ); ?>
					</span>
				</div>
				<button type="button"
					@mousedown.prevent="showDropdown = false"
					class="p-0.5 rounded text-gray-400 hover:text-gray-600 hover:bg-gray-200 cursor-pointer transition-colors">
					<span class="sr-only"><?php esc_html_e( 'Close', 'affiliate-wp' ); ?></span>
					<?php \AffiliateWP\Utils\Icons::render( 'x-mark', '', [ 'width' => '16', 'height' => '16' ] ); ?>
				</button>
			</div>
			<div class="max-h-48 overflow-y-auto" role="listbox" id="<?php echo $id; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $id is pre-escaped via esc_attr(). ?>-listbox">
				<?php if ( $args['include_groups'] ) : ?>
					<div x-show="<?php echo esc_attr( "{$store}.searchResults.some(r => r.type === 'group')" ); ?>" class="px-3 pt-1.5 pb-1">
						<span class="text-[11px] font-semibold uppercase tracking-wide text-gray-400"><?php esc_html_e( 'Groups', 'affiliate-wp' ); ?></span>
					</div>
					<template x-for="(grp, gi) in <?php echo esc_attr( "{$store}.searchResults.filter(r => r.type === 'group')" ); ?>" :key="'g' + grp.id">
						<button type="button"
							@mousedown.prevent="<?php echo esc_attr( "!{$store}.isGroupSelected(grp) && {$store}.addGroup(grp)" ); ?>"
							@click="<?php echo esc_attr( "!{$store}.isGroupSelected(grp) && {$store}.addGroup(grp)" ); ?>"
							x-effect="<?php echo esc_attr( "{$store}.activeIndex === gi" ); ?> && $el.scrollIntoView({ block: 'nearest' })"
							tabindex="-1"
							role="option"
							:id="<?php echo esc_attr( "'{$id}-opt-' + gi" ); ?>"
							:aria-selected="<?php echo esc_attr( "{$store}.activeIndex === gi" ); ?>"
							:class="[ <?php echo esc_attr( "{$store}.isGroupSelected(grp)" ); ?>
								? 'flex items-center justify-between w-full px-3 py-2 text-sm text-gray-400 cursor-default'
								: 'flex items-center justify-between w-full px-3 py-2 text-sm text-gray-700 hover:bg-affwp-brand-500/5 hover:text-affwp-brand-500 focus:outline-hidden focus-visible:bg-affwp-brand-500/5 focus-visible:text-affwp-brand-500 cursor-pointer transition-colors', { 'bg-affwp-brand-500/5 text-affwp-brand-500': <?php echo esc_attr( "{$store}.activeIndex === gi && ! {$store}.isGroupSelected(grp)" ); ?> } ]">
							<span class="flex items-center gap-1.5 min-w-0">
								<span class="inline-flex shrink-0" :class="<?php echo esc_attr( "{$store}.isGroupSelected(grp)" ); ?> ? 'text-gray-300' : 'text-affwp-brand-500/70'">
									<?php \AffiliateWP\Utils\Icons::render( 'users-2', '', [ 'class' => 'size-4', 'width' => '16', 'height' => '16' ] ); ?>
								</span>
								<span x-text="grp.name" class="truncate"></span>
								<span class="shrink-0 text-[11px] text-gray-400" x-text="'(' + (grp.affiliate_count || 0) + ')'"></span>
							</span>
							<span x-show="<?php echo esc_attr( "{$store}.isGroupSelected(grp)" ); ?>" class="inline-flex items-center gap-1 text-xs text-affwp-brand-500">
								<?php esc_html_e( 'Selected', 'affiliate-wp' ); ?>
								<?php \AffiliateWP\Utils\Icons::render( 'check', '', [ 'width' => '14', 'height' => '14' ] ); ?>
							</span>
							<span x-show="<?php echo esc_attr( "!{$store}.isGroupSelected(grp)" ); ?>" class="text-gray-300">
								<?php \AffiliateWP\Utils\Icons::render( 'plus', '', [ 'width' => '16', 'height' => '16' ] ); ?>
							</span>
						</button>
					</template>
					<div x-show="<?php echo esc_attr( "{$store}.searchResults.some(r => r.type === 'group') && {$store}.searchResults.some(r => r.type !== 'group')" ); ?>" class="px-3 pt-1.5 pb-1 border-t border-gray-100">
						<span class="text-[11px] font-semibold uppercase tracking-wide text-gray-400"><?php esc_html_e( 'Affiliates', 'affiliate-wp' ); ?></span>
					</div>
					<template x-for="(aff, ai) in <?php echo esc_attr( "{$store}.searchResults.filter(r => r.type !== 'group')" ); ?>" :key="'a' + aff.id">
						<button type="button"
							<?php // mousedown.prevent keeps the search input focused on mouse; @click adds on mouse-completion AND keyboard (Space/Enter fire click, not mousedown). The isSelected guard makes the mouse double-fire a no-op. ?>
							@mousedown.prevent="<?php echo esc_attr( "!{$store}.isSelected(aff) && {$store}.addAffiliate(aff){$on_pick_extra}" ); ?>"
							@click="<?php echo esc_attr( "!{$store}.isSelected(aff) && {$store}.addAffiliate(aff){$on_pick_extra}" ); ?>"
							x-effect="<?php echo esc_attr( "{$store}.activeIndex === {$store}.groupCount + ai" ); ?> && $el.scrollIntoView({ block: 'nearest' })"
							tabindex="-1"
							role="option"
							:id="<?php echo esc_attr( "'{$id}-opt-' + ( {$store}.groupCount + ai )" ); ?>"
							:aria-selected="<?php echo esc_attr( "{$store}.activeIndex === {$store}.groupCount + ai" ); ?>"
							:class="[ <?php echo esc_attr( "{$store}.isSelected(aff)" ); ?>
								? 'flex items-center justify-between w-full px-3 py-2 text-sm text-gray-400 cursor-default'
								: 'flex items-center justify-between w-full px-3 py-2 text-sm text-gray-700 hover:bg-affwp-brand-500/5 hover:text-affwp-brand-500 focus:outline-hidden focus-visible:bg-affwp-brand-500/5 focus-visible:text-affwp-brand-500 cursor-pointer transition-colors', { 'bg-affwp-brand-500/5 text-affwp-brand-500': <?php echo esc_attr( "{$store}.activeIndex === {$store}.groupCount + ai && ! {$store}.isSelected(aff)" ); ?> } ]">
							<span class="flex items-center gap-1.5 min-w-0">
								<span x-text="aff.name" class="truncate"></span>
								<?php if ( $args['show_group'] ) : ?>
									<span x-show="aff.group_name && !<?php echo esc_attr( "{$store}.isSelected(aff)" ); ?>" class="shrink-0 text-[11px] text-gray-400">
										<span class="text-gray-300 mx-0.5">&middot;</span>
										<span x-text="aff.group_name"></span>
									</span>
								<?php endif; ?>
							</span>
							<span x-show="<?php echo esc_attr( "{$store}.isSelected(aff)" ); ?>" class="inline-flex items-center gap-1 text-xs text-affwp-brand-500">
								<?php esc_html_e( 'Selected', 'affiliate-wp' ); ?>
								<?php \AffiliateWP\Utils\Icons::render( 'check', '', [ 'width' => '14', 'height' => '14' ] ); ?>
							</span>
							<span x-show="<?php echo esc_attr( "!{$store}.isSelected(aff)" ); ?>" class="text-gray-300">
								<?php \AffiliateWP\Utils\Icons::render( 'plus', '', [ 'width' => '16', 'height' => '16' ] ); ?>
							</span>
						</button>
					</template>
				<?php else : ?>
					<template x-for="(aff, ai) in <?php echo esc_attr( "{$store}.searchResults" ); ?>" :key="aff.id">
						<button type="button"
							<?php // mousedown.prevent keeps the search input focused on mouse; @click adds on mouse-completion AND keyboard (Space/Enter fire click, not mousedown). The isSelected guard makes the mouse double-fire a no-op. ?>
							@mousedown.prevent="<?php echo esc_attr( "!{$store}.isSelected(aff) && {$store}.addAffiliate(aff){$on_pick_extra}" ); ?>"
							@click="<?php echo esc_attr( "!{$store}.isSelected(aff) && {$store}.addAffiliate(aff){$on_pick_extra}" ); ?>"
							x-effect="<?php echo esc_attr( "{$store}.activeIndex === ai" ); ?> && $el.scrollIntoView({ block: 'nearest' })"
							tabindex="-1"
							role="option"
							:id="<?php echo esc_attr( "'{$id}-opt-' + ai" ); ?>"
							:aria-selected="<?php echo esc_attr( "{$store}.activeIndex === ai" ); ?>"
							:class="[ <?php echo esc_attr( "{$store}.isSelected(aff)" ); ?>
								? 'flex items-center justify-between w-full px-3 py-2 text-sm text-gray-400 cursor-default'
								: 'flex items-center justify-between w-full px-3 py-2 text-sm text-gray-700 hover:bg-affwp-brand-500/5 hover:text-affwp-brand-500 focus:outline-hidden focus-visible:bg-affwp-brand-500/5 focus-visible:text-affwp-brand-500 cursor-pointer transition-colors', { 'bg-affwp-brand-500/5 text-affwp-brand-500': <?php echo esc_attr( "{$store}.activeIndex === ai && ! {$store}.isSelected(aff)" ); ?> } ]">
							<span class="flex items-center gap-1.5 min-w-0">
								<span x-text="aff.name" class="truncate"></span>
								<?php if ( $args['show_group'] ) : ?>
									<span x-show="aff.group_name && !<?php echo esc_attr( "{$store}.isSelected(aff)" ); ?>"
										class="shrink-0 text-[11px] text-gray-400">
										<span class="text-gray-300 mx-0.5">&middot;</span>
										<span x-text="aff.group_name"></span>
									</span>
								<?php endif; ?>
							</span>
							<span x-show="<?php echo esc_attr( "{$store}.isSelected(aff)" ); ?>" class="inline-flex items-center gap-1 text-xs text-affwp-brand-500">
								<?php esc_html_e( 'Selected', 'affiliate-wp' ); ?>
								<?php \AffiliateWP\Utils\Icons::render( 'check', '', [ 'width' => '14', 'height' => '14' ] ); ?>
							</span>
							<span x-show="<?php echo esc_attr( "!{$store}.isSelected(aff)" ); ?>" class="text-gray-300">
								<?php \AffiliateWP\Utils\Icons::render( 'plus', '', [ 'width' => '16', 'height' => '16' ] ); ?>
							</span>
						</button>
					</template>
				<?php endif; ?>
			</div>
		</div>
		<?php
	};

	ob_start();
	?>
	<div>
		<?php if ( ! empty( $args['label'] ) ) : ?>
			<label for="<?php echo $id; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $id is pre-escaped via esc_attr() at assignment. ?>-search" class="block text-sm font-medium text-gray-900 mb-2">
				<?php echo esc_html( $args['label'] ); ?>
			</label>
		<?php endif; ?>

		<?php if ( $chips_inside_input ) : ?>
		<!--
			Combobox mode: chips + input in one bordered container. Click anywhere on the
			container focuses the input and opens the dropdown. Visually mirrors the v2
			prototype's combobox treatment (Gmail recipients / Notion tags style).
		-->
		<div x-data="{ showDropdown: false, showAll: false }"
			@click.outside="showDropdown = false"
			@keydown.escape.window="showDropdown = false"
			class="relative">
			<div class="flex <?php echo esc_attr( $size['combobox_min_h'] ); ?> w-full flex-wrap items-center gap-1.5 rounded-md bg-white p-1 ring-1 ring-gray-300 focus-within:ring-2 focus-within:ring-affwp-brand-500 hover:ring-gray-400 transition-shadow cursor-text"
				@click="$refs.affwpPickerInput && $refs.affwpPickerInput.focus(); showDropdown = true">
				<?php if ( $args['include_groups'] ) : ?>
					<!-- Group chips inside combobox -->
					<template x-for="grp in <?php echo esc_attr( "{$store}.selectedGroups" ); ?>" :key="'g' + grp.id">
						<span class="inline-flex items-center gap-1 rounded-md bg-affwp-brand-500/10 <?php echo esc_attr( $size['chip'] ); ?> font-medium text-affwp-brand-700 ring-1 ring-affwp-brand-500/20">
							<?php \AffiliateWP\Utils\Icons::render( 'users-2', '', [ 'class' => $size['chip_icon'] . ' shrink-0' ] ); ?>
							<span x-text="grp.name"></span>
							<span class="text-affwp-brand-600/70" x-text="'(' + (grp.affiliate_count || 0) + ')'"></span>
							<button type="button"
								@click.stop="<?php echo esc_attr( "{$store}.removeGroup(grp)" ); ?>"
								class="shrink-0 rounded p-0.5 text-affwp-brand-500 hover:text-affwp-brand-700 hover:bg-affwp-brand-500/15 transition-colors cursor-pointer">
								<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor"><path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" /></svg>
							</button>
						</span>
					</template>
				<?php endif; ?>
				<!-- Affiliate chips inside combobox -->
				<template x-for="(aff, index) in <?php echo esc_attr( "{$store}.selectedAffiliates" ); ?>" :key="'a' + aff.id">
					<span x-show="<?php echo $truncates_chips ? esc_attr( "showAll || index < {$truncate_chips_at}" ) : 'true'; ?>" class="inline-flex items-center gap-1 rounded-md bg-gray-100 <?php echo esc_attr( $size['chip'] ); ?> font-medium text-gray-700 ring-1 ring-gray-200">
						<span x-text="aff.name"></span>
						<button type="button"
							@click.stop="<?php echo esc_attr( "{$store}.removeAffiliate(aff)" ); ?>"
							class="shrink-0 rounded p-0.5 text-gray-400 hover:text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer">
							<svg class="h-3 w-3" viewBox="0 0 20 20" fill="currentColor"><path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" /></svg>
						</button>
					</span>
				</template>
				<?php if ( $truncates_chips ) : ?>
					<button x-show="!showAll && <?php echo esc_attr( "{$store}.selectedAffiliates.length > {$truncate_chips_at}" ); ?>"
						x-cloak
						@click.stop="showAll = true"
						type="button"
						class="inline-flex items-center rounded-md bg-gray-100 <?php echo esc_attr( $size['more_button'] ); ?> font-medium text-gray-500 ring-1 ring-gray-200 hover:bg-gray-200 cursor-pointer transition-colors">
						<span x-text="'+' + (<?php echo esc_attr( "{$store}.selectedAffiliates.length - {$truncate_chips_at}" ); ?>) + ' <?php echo esc_js( __( 'more', 'affiliate-wp' ) ); ?>'"></span>
					</button>
					<button x-show="showAll && <?php echo esc_attr( "{$store}.selectedAffiliates.length > {$truncate_chips_at}" ); ?>"
						x-cloak
						@click.stop="showAll = false"
						type="button"
						class="inline-flex items-center rounded-md bg-gray-100 <?php echo esc_attr( $size['more_button'] ); ?> font-medium text-gray-500 ring-1 ring-gray-200 hover:bg-gray-200 cursor-pointer transition-colors">
						<?php esc_html_e( 'Show less', 'affiliate-wp' ); ?>
					</button>
				<?php endif; ?>
				<!-- Trailing input slot (borderless, grows to fill remaining row width) -->
				<input
					type="text"
					id="<?php echo $id; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $id is pre-escaped via esc_attr() at assignment. ?>-search"
					x-ref="affwpPickerInput"
					autocomplete="off"
					x-model="<?php echo esc_attr( "{$store}.affiliateSearch" ); ?>"
					@input="<?php echo esc_attr( "{$store}.searchAffiliates(\$el.value)" ); ?>; showDropdown = true"
					@focus="showDropdown = true; <?php echo esc_attr( "{$store}.loadSuggestions()" ); ?>"
					@click.stop="showDropdown = true"
					@keydown.escape.stop="showDropdown = false; <?php echo esc_attr( "{$store}.affiliateSearch" ); ?> = ''"
					@keydown.down.prevent="showDropdown = true; <?php echo esc_attr( "{$store}.moveActive(1)" ); ?>"
					@keydown.up.prevent="<?php echo esc_attr( "{$store}.moveActive(-1)" ); ?>"
					@keydown.enter.prevent="<?php echo esc_attr( "{$store}.pickActive(){$on_pick_extra}" ); ?>"
					role="combobox"
					aria-autocomplete="list"
					aria-controls="<?php echo $id; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $id is pre-escaped via esc_attr(). ?>-listbox"
					:aria-expanded="showDropdown"
					:aria-activedescendant="<?php echo esc_attr( "{$store}.activeIndex >= 0 ? '{$id}-opt-' + {$store}.activeIndex : null" ); ?>"
					placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
					class="min-w-32 flex-1 bg-transparent px-2 <?php echo esc_attr( $size['combobox_input'] ); ?> text-gray-900 placeholder-gray-400 focus:outline-none"
				/>
			</div>

			<?php $render_dropdown(); ?>
		</div>
		<?php else : ?>
		<!-- Selected tags (groups first, then affiliates; both flow together) -->
		<div x-show="<?php echo esc_attr( "{$store}.selectedAffiliates.length > 0 || ({$store}.selectedGroups && {$store}.selectedGroups.length > 0)" ); ?>" x-cloak x-data="{ showAll: false }" class="flex flex-wrap gap-1.5 mb-3">
			<?php if ( $args['include_groups'] ) : ?>
				<template x-for="grp in <?php echo esc_attr( "{$store}.selectedGroups" ); ?>" :key="'g' + grp.id">
					<span class="inline-flex items-center gap-1 rounded-full bg-affwp-brand-500/10 px-2.5 py-1 text-xs font-medium text-affwp-brand-700 ring-1 ring-affwp-brand-500/20">
						<?php \AffiliateWP\Utils\Icons::render( 'users-2', '', [ 'class' => 'size-3.5 shrink-0', 'width' => '14', 'height' => '14' ] ); ?>
						<span x-text="grp.name"></span>
						<span class="text-affwp-brand-600/70" x-text="'(' + (grp.affiliate_count || 0) + ')'"></span>
						<button type="button"
							@click="<?php echo esc_attr( "{$store}.removeGroup(grp)" ); ?>"
							class="shrink-0 rounded-full p-1 -mr-0.5 text-affwp-brand-500 hover:text-affwp-brand-700 hover:bg-affwp-brand-500/15 transition-colors cursor-pointer">
							<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
								<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
							</svg>
						</button>
					</span>
				</template>
			<?php endif; ?>
			<template x-for="(aff, index) in <?php echo esc_attr( "{$store}.selectedAffiliates" ); ?>" :key="'a' + aff.id">
				<span x-show="<?php echo $truncates_chips ? esc_attr( "showAll || index < {$truncate_chips_at}" ) : 'true'; ?>" class="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 ring-1 ring-gray-200">
					<span x-text="aff.name"></span>
					<button type="button"
						@click="<?php echo esc_attr( "{$store}.removeAffiliate(aff)" ); ?>"
						class="shrink-0 rounded-full p-1 -mr-0.5 text-gray-400 hover:text-gray-600 hover:bg-gray-200 transition-colors cursor-pointer">
						<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
							<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
						</svg>
					</button>
				</span>
			</template>
			<?php if ( $truncates_chips ) : ?>
				<!-- "+N more" indicator when capped. Skipped entirely when truncate_chips_at is disabled — chips just wrap. -->
				<button
					x-show="!showAll && <?php echo esc_attr( "{$store}.selectedAffiliates.length > {$truncate_chips_at}" ); ?>"
					@click="showAll = true"
					type="button"
					class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1.5 text-xs font-medium text-gray-500 ring-1 ring-gray-200 hover:bg-gray-200 cursor-pointer transition-colors">
					<span x-text="'+' + (<?php echo esc_attr( "{$store}.selectedAffiliates.length - {$truncate_chips_at}" ); ?>) + ' <?php echo esc_js( __( 'more', 'affiliate-wp' ) ); ?>'"></span>
				</button>
				<!-- Collapse link when expanded -->
				<button
					x-show="showAll && <?php echo esc_attr( "{$store}.selectedAffiliates.length > {$truncate_chips_at}" ); ?>"
					@click="showAll = false"
					type="button"
					class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-1.5 text-xs font-medium text-gray-500 ring-1 ring-gray-200 hover:bg-gray-200 cursor-pointer transition-colors">
					<?php esc_html_e( 'Show less', 'affiliate-wp' ); ?>
				</button>
			<?php endif; ?>
		</div>

		<!-- Search input with dropdown -->
		<div x-data="{ showDropdown: false }"
			<?php if ( $single ) : ?>
			<?php /* Single-select: hide the search once one is picked — the chip (with its X) is the way to change it. */ ?>
			x-show="<?php echo esc_attr( "{$store}.selectedAffiliates.length === 0" ); ?>"
			x-cloak
			<?php endif; ?>
			@click.outside="showDropdown = false"
			@keydown.escape.window="showDropdown = false"
			class="relative">
			<input
				type="text"
				id="<?php echo $id; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $id is pre-escaped via esc_attr() at assignment. ?>-search"
				autocomplete="off"
				x-model="<?php echo esc_attr( "{$store}.affiliateSearch" ); ?>"
				@input="<?php echo esc_attr( "{$store}.searchAffiliates(\$el.value)" ); ?>; showDropdown = true"
				@focus="showDropdown = true; <?php echo esc_attr( "{$store}.loadSuggestions()" ); ?>"
				@click="showDropdown = true"
				@keydown.escape.stop="showDropdown = false; <?php echo esc_attr( "{$store}.affiliateSearch" ); ?> = ''"
				@keydown.down.prevent="showDropdown = true; <?php echo esc_attr( "{$store}.moveActive(1)" ); ?>"
				@keydown.up.prevent="<?php echo esc_attr( "{$store}.moveActive(-1)" ); ?>"
				@keydown.enter.prevent="<?php echo esc_attr( "{$store}.pickActive(){$on_pick_extra}" ); ?>"
				role="combobox"
				aria-autocomplete="list"
				aria-controls="<?php echo $id; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $id is pre-escaped via esc_attr(). ?>-listbox"
				:aria-expanded="showDropdown"
				:aria-activedescendant="<?php echo esc_attr( "{$store}.activeIndex >= 0 ? '{$id}-opt-' + {$store}.activeIndex : null" ); ?>"
				placeholder="<?php echo esc_attr( $args['placeholder'] ); ?>"
				<?php if ( '' !== $error ) : ?>
				:aria-invalid="<?php echo esc_attr( "!! ( {$error} )" ); ?>"
				<?php /* Match RewardsWP's ring-2 ring-red-500 (red rings aren't in the admin build, so via :style). Sits over the gray border and stays while the error shows, even on focus. */ ?>
				:style="<?php echo esc_attr( "( {$error} ) ? 'box-shadow: 0 0 0 2px rgb(239 68 68)' : ''" ); ?>"
				<?php endif; ?>
				class="w-full px-3 <?php echo esc_attr( $size['standalone_in'] ); ?> rounded-md border border-gray-300 text-gray-900 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-affwp-brand-500 focus:border-affwp-brand-500 hover:border-gray-400 transition-colors"
			/>
			<?php $render_dropdown(); ?>
		</div>
		<?php if ( '' !== $error ) : ?>
			<!-- Field-level error message below the input (à la RewardsWP). -->
			<p x-show="<?php echo esc_attr( "!! ( {$error} )" ); ?>" x-cloak
				class="mt-1.5 text-sm text-red-600 m-0"
				x-text="<?php echo esc_attr( $error ); ?>"></p>
		<?php endif; ?>
		<?php endif; ?>

		<!-- Selected count -->
		<?php if ( $args['include_groups'] ) : ?>
			<div x-show="<?php echo esc_attr( "{$store}.selectedAffiliates.length > 0 || ({$store}.selectedGroups && {$store}.selectedGroups.length > 0)" ); ?>" x-cloak class="mt-1.5">
				<span class="text-xs text-gray-500">
					<span x-text="(<?php echo esc_attr( "{$store}.selectedGroups ? {$store}.selectedGroups.length : 0" ); ?>) + (<?php echo esc_attr( "{$store}.selectedAffiliates.length" ); ?>)"></span> <?php esc_html_e( 'selected', 'affiliate-wp' ); ?>
				</span>
			</div>
		<?php elseif ( ! $single ) : // The "N selected" count is multi-select chrome; pointless for a single value. ?>
			<div x-show="<?php echo esc_attr( "{$store}.selectedAffiliates.length > 0" ); ?>" x-cloak class="mt-1.5">
				<span class="text-xs text-gray-500">
					<span x-text="<?php echo esc_attr( "{$store}.selectedAffiliates.length" ); ?>"></span> <?php esc_html_e( 'selected', 'affiliate-wp' ); ?>
				</span>
			</div>
		<?php endif; ?>

		<?php if ( ! empty( $args['description'] ) ) : ?>
			<p class="mt-1.5 text-sm text-gray-500 m-0">
				<?php echo esc_html( $args['description'] ); ?>
			</p>
		<?php endif; ?>
	</div>
	<?php
	return ob_get_clean();
}

/**
 * Get the Alpine.js state and methods for an affiliate picker.
 *
 * Merge the returned array into your Alpine store to power the picker UI.
 * The AJAX search uses the shared `affwp_search_affiliates` action.
 *
 * @since 2.33.0
 *
 * @param string $nonce A valid nonce for the `affwp_affiliate_picker` action.
 * @return array {
 *     @type array  $state   Keys to spread into the Alpine store's initial state.
 *     @type string $methods JS code fragment defining the picker methods.
 * }
 */
function affwp_affiliate_picker_alpine( $nonce, $args = [] ) {

	$args = wp_parse_args( $args, [
		'include_group'  => false,
		'include_groups' => false,
	] );

	$include_group_param  = $args['include_group'] ? "params.set('include_group', '1');" : '';
	$include_groups_param = $args['include_groups'] ? "params.set('include_groups', '1');" : '';

	$state = [
		'searchResults'      => [],
		'selectedAffiliates' => [],
		'affiliateSearch'    => '',
		'searchLoading'      => false,
		'_searchTimer'       => null,
		'activeIndex'        => -1, // Keyboard-highlighted row in the dropdown (-1 = none). Indexes orderedResults.
	];

	if ( $args['include_groups'] ) {
		$state['selectedGroups'] = [];
	}

	// Build the methods as a JS string that can be spread into an Alpine store.
	$methods = <<<JS
searchAffiliates(query) {
	clearTimeout(this._searchTimer);
	this.searchLoading = true;
	this._searchTimer = setTimeout(async () => {
		const params = new URLSearchParams({
			action: 'affwp_search_affiliates',
			_wpnonce: '{$nonce}',
			search: query,
		});
		{$include_group_param}
		{$include_groups_param}
		try {
			const response = await fetch(ajaxurl + '?' + params.toString());
			const result = await response.json();
			if (result.success) {
				this.searchResults = result.data;
				this.activeIndex = -1;
			}
		} catch (e) {}
		this.searchLoading = false;
	}, 250);
},
loadSuggestions() {
	// Always refresh on empty-search focus so stale typed-search results aren't shown as recents.
	this.searchLoading = true;
	const params = new URLSearchParams({
		action: 'affwp_search_affiliates',
		_wpnonce: '{$nonce}',
		search: '',
	});
	{$include_group_param}
	{$include_groups_param}
	fetch(ajaxurl + '?' + params.toString())
		.then(r => r.json())
		.then(result => {
			if (result.success) { this.searchResults = result.data; this.activeIndex = -1; }
		})
		.catch(() => {})
		.finally(() => { this.searchLoading = false; });
},
isSelected(aff) {
	return this.selectedAffiliates.some(a => a.id === aff.id);
},
addAffiliate(aff) {
	if (this.isSelected(aff)) return;
	this.selectedAffiliates = [...this.selectedAffiliates, aff];
	this.affiliateSearch = '';
	if (this._markDirty) this._markDirty();
},
removeAffiliate(aff) {
	this.selectedAffiliates = this.selectedAffiliates.filter(a => a.id !== aff.id);
	if (this._markDirty) this._markDirty();
},
isGroupSelected(grp) {
	return (this.selectedGroups || []).some(g => g.id === grp.id);
},
addGroup(grp) {
	if (this.isGroupSelected(grp)) return;
	this.selectedGroups = [...(this.selectedGroups || []), grp];
	this.affiliateSearch = '';
	if (this._markDirty) this._markDirty();
},
removeGroup(grp) {
	this.selectedGroups = (this.selectedGroups || []).filter(g => g.id !== grp.id);
	if (this._markDirty) this._markDirty();
},
// Dropdown rows in visual order (groups first, then affiliates) — the list arrow-key nav walks.
get orderedResults() {
	return [ ...this.searchResults.filter(r => r.type === 'group'), ...this.searchResults.filter(r => r.type !== 'group') ];
},
get groupCount() {
	return this.searchResults.filter(r => r.type === 'group').length;
},
// Move the keyboard highlight; first press from "none" lands on the first (or last) row.
moveActive(delta) {
	const n = this.orderedResults.length;
	if (!n) { this.activeIndex = -1; return; }
	this.activeIndex = this.activeIndex < 0
		? (delta > 0 ? 0 : n - 1)
		: Math.max(0, Math.min(n - 1, this.activeIndex + delta));
},
// Add the highlighted row (mirrors a click); no-op when nothing is highlighted or it's already selected.
pickActive() {
	const it = this.orderedResults[this.activeIndex];
	if (!it) return;
	if (it.type === 'group') {
		if (!this.isGroupSelected(it)) this.addGroup(it);
	} else if (!this.isSelected(it)) {
		this.addAffiliate(it);
	}
},
JS;

	return [
		'state'   => $state,
		'methods' => $methods,
	];
}

/**
 * Render a segmented control (pill-style radio group).
 *
 * @since 2.33.0
 *
 * @param array $args {
 *     Configuration arguments.
 *
 *     @type string $name        HTML radio name attribute. Required.
 *     @type string $alpine_model Alpine x-model binding. Required.
 *     @type array  $options     Associative array of value => label pairs. Required.
 *     @type string $label       Optional label rendered above the control.
 *     @type string $aria_label  Accessible label for the radiogroup. Defaults to $label.
 * }
 */
function affwp_segmented_control( $args = [] ) {
	echo affwp_render_segmented_control( $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- affwp_render_segmented_control() escapes name/value/aria/x-model via esc_attr() and labels via esc_html(); wp_kses would strip the x-model binding.
}

/**
 * Get segmented control HTML.
 *
 * @since 2.33.0
 *
 * @param array $args See {@see affwp_segmented_control()} for accepted arguments.
 * @return string The control HTML.
 */
function affwp_render_segmented_control( $args = [] ) {

	$args = wp_parse_args( $args, [
		'name'         => '',
		'alpine_model' => '',
		'options'      => [],
		'label'        => '',
		'aria_label'   => '',
	] );

	if ( empty( $args['name'] ) || empty( $args['alpine_model'] ) || empty( $args['options'] ) ) {
		return '';
	}

	$aria = ! empty( $args['aria_label'] ) ? $args['aria_label'] : $args['label'];

	// String-cast so numeric option keys (PHP casts them to ints) still match the model value.
	$values_json = wp_json_encode( array_map( 'strval', array_keys( $args['options'] ) ) );

	ob_start();
	?>
	<div>
		<?php if ( ! empty( $args['label'] ) ) : ?>
			<label class="block text-sm font-medium text-gray-900 mb-2">
				<?php echo esc_html( $args['label'] ); ?>
			</label>
		<?php endif; ?>
		<div
			x-data="{ get segIndex() { return <?php echo esc_attr( $values_json ); ?>.indexOf( String( <?php echo esc_attr( $args['alpine_model'] ); ?> ) ); } }"
			class="isolate inline-grid auto-cols-fr grid-flow-col gap-1 rounded-full bg-gray-950/10 p-1"
			role="radiogroup"
			aria-label="<?php echo esc_attr( $aria ); ?>"
		>
			<?php $seg_col = 0; ?>
			<?php foreach ( $args['options'] as $value => $option_label ) : $seg_col++; ?>
				<?php // Explicit placement: auto-flow would skip the cell the thumb claims and shift every label one column right. ?>
				<label class="cursor-pointer" style="grid-area: 1 / <?php echo (int) $seg_col; ?>">
					<input
						type="radio"
						name="<?php echo esc_attr( $args['name'] ); ?>"
						value="<?php echo esc_attr( $value ); ?>"
						x-model="<?php echo esc_attr( $args['alpine_model'] ); ?>"
						class="sr-only peer"
					/>
					<span class="flex items-center justify-center rounded-full px-4 text-sm/7 font-medium text-gray-700 peer-checked:text-gray-900 peer-focus-visible:ring-2 peer-focus-visible:ring-affwp-brand-500 peer-focus-visible:ring-offset-2 transition-colors duration-200">
						<?php echo esc_html( $option_label ); ?>
					</span>
				</label>
			<?php endforeach; ?>
			<?php
			/*
			 * Sliding thumb. Equal-width segments let the index alone position it
			 * (translateX step = own width + the container's gap-1), so no DOM
			 * measurement is needed and it stays correct inside hidden drawers.
			 */
			?>
			<span
				aria-hidden="true"
				class="pointer-events-none col-start-1 row-start-1 -z-10 rounded-full bg-white ring ring-gray-950/5 transition duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] motion-reduce:transition-none"
				:class="segIndex < 0 && 'opacity-0'"
				:style="`transform: translateX(calc(${ Math.max( 0, segIndex ) } * (100% + 0.25rem)))`"
			></span>
		</div>
	</div>
	<?php
	return ob_get_clean();
}

/**
 * Render a Pro upsell card.
 *
 * Designed for inline use inside an admin surface (drawer, settings page,
 * list-table row) where a feature is gated and we want to surface the
 * upgrade in context rather than a modal. Small, icon-led, two visible
 * accents (the icon tile + the primary CTA), neutral surface.
 *
 * Originally designed for the QR Code upsell in the creative drawer, then
 * generalized so any Pro-gated feature can reuse the pattern.
 *
 * @since 2.33.0
 *
 * @param array $args {
 *     Upsell card arguments.
 *
 *     @type string $icon_svg        Raw SVG markup for the feature icon. Uses currentColor; tint via icon_class. Required.
 *     @type string $heading         Feature name. Required.
 *     @type string $copy            Short one-paragraph value prop. Required.
 *     @type string $upgrade_url     Destination for the upgrade CTA (usually affwp_admin_upgrade_link()). Required.
 *     @type string $upgrade_text    CTA label. Default "Upgrade to Pro".
 *     @type string $tier_label      Small kicker next to the heading. Default "Pro".
 *     @type string $dismiss_text    Label for the left-side dismiss control. Omit to hide.
 *     @type string $dismiss_onclick Alpine or plain onclick expression for dismiss. Required if dismiss_text is set.
 *     @type string $icon_class      Tailwind classes applied to the icon SVG (size + color). Default "size-8 text-affwp-brand-600".
 *     @type string $class           Extra classes on the root container.
 * }
 * @return string Rendered HTML.
 */
function affwp_render_pro_upsell_card( $args = [] ) {
	$defaults = [
		'icon_svg'        => '',
		'heading'         => '',
		'copy'            => '',
		'upgrade_url'     => '',
		'upgrade_text'    => __( 'Upgrade to Pro', 'affiliate-wp' ),
		'tier_label'      => __( 'Pro', 'affiliate-wp' ),
		'dismiss_text'    => '',
		'dismiss_onclick' => '',
		'icon_class'      => 'size-8 text-affwp-brand-600',
		'class'           => '',
	];

	$args = wp_parse_args( $args, $defaults );

	if ( '' === $args['icon_svg'] || '' === $args['heading'] || '' === $args['copy'] || '' === $args['upgrade_url'] ) {
		return '';
	}

	$show_dismiss = ( '' !== $args['dismiss_text'] && '' !== $args['dismiss_onclick'] );

	ob_start();
	?>
	<div class="rounded-xl bg-white ring-1 ring-gray-200 p-5 shadow-xs <?php echo esc_attr( $args['class'] ); ?>">
		<div class="flex items-center gap-4">
			<?php // Icon seated on a brand-tinted tile. Gives the icon product presence without overloading surfaces elsewhere in the card. ?>
			<div class="shrink-0 flex items-center justify-center size-16 rounded-xl bg-affwp-brand-bg ring-1 ring-affwp-brand-500/15 [&>svg]:<?php echo esc_attr( $args['icon_class'] ); ?>">
				<?php echo $args['icon_svg']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
			</div>

			<div class="flex-1 min-w-0">
				<div class="flex items-center gap-2 mb-1">
					<h3 class="text-base font-semibold text-gray-900 m-0"><?php echo esc_html( $args['heading'] ); ?></h3>
					<span class="text-xs font-semibold uppercase tracking-wide text-affwp-brand-600"><?php echo esc_html( $args['tier_label'] ); ?></span>
				</div>
				<p class="text-sm text-gray-600 leading-relaxed m-0"><?php echo esc_html( $args['copy'] ); ?></p>
			</div>
		</div>

		<div class="flex items-center justify-between gap-3 mt-5 pt-4 border-t border-gray-100">
			<?php if ( $show_dismiss ) : ?>
				<button type="button"
					@click="<?php echo esc_attr( $args['dismiss_onclick'] ); ?>"
					class="cursor-pointer text-sm text-gray-600 hover:text-gray-900 bg-transparent border-0 p-0 transition-colors">
					<?php echo esc_html( $args['dismiss_text'] ); ?>
				</button>
			<?php else : ?>
				<span></span>
			<?php endif; ?>
			<a href="<?php echo esc_url( $args['upgrade_url'] ); ?>"
				target="_blank" rel="noopener noreferrer"
				class="inline-flex items-center gap-1.5 rounded-md bg-affwp-brand-500 px-4 py-2 text-sm font-semibold text-white hover:bg-affwp-brand-600 no-underline transition-colors shadow-sm">
				<?php echo esc_html( $args['upgrade_text'] ); ?>
				<svg class="size-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M14 5l7 7m0 0l-7 7m7-7H3"/></svg>
			</a>
		</div>
	</div>
	<?php
	return ob_get_clean();
}

/**
 * Render a Pro upsell overlay card for gated surfaces.
 *
 * The overlay sibling of affwp_render_pro_upsell_card(): a centered, stacked
 * card shown over a gated form. Gate the form itself with
 * `blur-xs pointer-events-none select-none` + `inert`, wrap both in a
 * `relative` container, and render this after the form. Anatomy mirrors the
 * RewardsWP upgrade overlay: feature icon, title, description, optional
 * data-aware value line, plan-requirement line, upgrade CTA, compare link.
 * The card is sticky so the pitch stays in view while the gated form scrolls.
 *
 * @since 2.35.0
 *
 * @param array $args {
 *     Overlay card arguments.
 *
 *     @type string $icon_svg    Raw SVG markup for the feature icon (currentColor, sized to 48px). Optional.
 *     @type string $title       Feature name. Required.
 *     @type string $description Short one-paragraph value prop. Required.
 *     @type array  $value_line  Optional data-aware line. Either [ 'text' => string ] or
 *                               [ 'x_text' => expr, 'x_show' => expr ] for Alpine-bound values.
 *     @type string $utm_medium  utm_medium for the upgrade link.
 *     @type string $utm_content utm_content for the upgrade CTA. Default 'upgrade-to-pro'.
 *     @type string $required    Minimum license level, as accepted by affwp_is_upgrade_required(). Default 'pro'.
 *     @type string $compare_url Optional "Compare plans" link below the CTA. Only pass a URL that goes
 *                               somewhere the CTA doesn't (a dedicated upgrade page vs the pricing page);
 *                               omitted = no link, since duplicating the CTA's destination is noise.
 * }
 * @return string Rendered HTML.
 */
function affwp_render_pro_upsell_overlay( $args = [] ) {
	$defaults = [
		'icon_svg'    => '',
		'title'       => '',
		'description' => '',
		'value_line'  => [],
		'utm_medium'  => 'upsell-overlay',
		'utm_content' => 'upgrade-to-pro',
		'required'    => 'pro',
		'compare_url' => '',
	];

	$args = wp_parse_args( $args, $defaults );

	if ( '' === $args['title'] || '' === $args['description'] ) {
		return '';
	}

	$tier_labels = [
		'plus'     => __( 'Plus', 'affiliate-wp' ),
		'pro'      => __( 'Pro', 'affiliate-wp' ),
		'ultimate' => __( 'Ultimate', 'affiliate-wp' ),
	];

	$required       = isset( $tier_labels[ $args['required'] ] ) ? $args['required'] : 'pro';
	$required_label = $tier_labels[ $required ];

	// "(or higher)" is meaningless on the top tier.
	$or_higher = 'ultimate' === $required ? '' : ' ' . __( '(or higher)', 'affiliate-wp' );

	$license_data = new \AffWP\Core\License\License_Data();
	$current_plan = $license_data->get_license_type( $license_data->get_license_id() );

	// affwp_admin_upgrade_link() is admin-only; fall back to the bare pricing URL elsewhere.
	$upgrade_url = function_exists( 'affwp_admin_upgrade_link' )
		? affwp_admin_upgrade_link( $args['utm_medium'], $args['utm_content'] )
		: 'https://affiliatewp.com/pricing/';

	$value_x_text = isset( $args['value_line']['x_text'] ) ? $args['value_line']['x_text'] : '';
	$value_x_show = isset( $args['value_line']['x_show'] ) ? $args['value_line']['x_show'] : $value_x_text;
	$value_text   = isset( $args['value_line']['text'] ) ? $args['value_line']['text'] : '';

	ob_start();
	?>
	<div class="absolute inset-0 z-10 flex items-start justify-center px-6 pt-10">
		<div class="sticky top-10 w-full max-w-md rounded-2xl bg-white p-8 text-center shadow-xl ring-1 ring-gray-200">
			<?php if ( '' !== $args['icon_svg'] ) : ?>
				<div class="mb-4 flex justify-center text-gray-700 [&>svg]:size-12">
					<?php echo $args['icon_svg']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
				</div>
			<?php endif; ?>
			<h3 class="m-0 text-lg font-semibold text-gray-900"><?php echo esc_html( $args['title'] ); ?></h3>
			<p class="mt-2 mb-0 text-sm/6 text-gray-500"><?php echo esc_html( $args['description'] ); ?></p>
			<?php if ( '' !== $value_x_text ) : ?>
				<p class="mt-3 mb-0 text-sm font-semibold text-gray-900" x-show="<?php echo esc_attr( $value_x_show ); ?>" x-cloak>
					<span x-text="<?php echo esc_attr( $value_x_text ); ?>"></span>
				</p>
			<?php elseif ( '' !== $value_text ) : ?>
				<p class="mt-3 mb-0 text-sm font-semibold text-gray-900"><?php echo esc_html( $value_text ); ?></p>
			<?php endif; ?>
			<?php // mt-6 splits the card into its two clusters: pitch above, transaction (plan line + CTA + compare) below. ?>
			<p class="mt-6 mb-0 text-xs text-gray-500">
				<?php esc_html_e( 'Required:', 'affiliate-wp' ); ?>
				<span class="font-semibold text-gray-700"><?php echo esc_html( $required_label . $or_higher ); ?></span>
				<?php if ( '' !== $current_plan ) : ?>
					&middot; <?php esc_html_e( 'Your plan:', 'affiliate-wp' ); ?> <?php echo esc_html( $current_plan ); ?>
				<?php endif; ?>
			</p>
			<a href="<?php echo esc_url( $upgrade_url ); ?>" target="_blank" rel="noopener noreferrer"
				class="mt-3 inline-flex w-full items-center justify-center rounded-md bg-affwp-brand-500 px-4 py-2.5 text-sm font-semibold text-white no-underline shadow-sm hover:bg-affwp-brand-600">
				<?php
				/* translators: %s: minimum plan name. */
				echo esc_html( sprintf( __( 'Upgrade to %s', 'affiliate-wp' ), $required_label ) );
				?>
			</a>
			<?php if ( '' !== $args['compare_url'] ) : ?>
				<div class="mt-3">
					<a href="<?php echo esc_url( $args['compare_url'] ); ?>" target="_blank" rel="noopener noreferrer"
						class="text-sm text-gray-500 no-underline underline-offset-4 hover:text-gray-700 hover:underline">
						<?php esc_html_e( 'Compare plans', 'affiliate-wp' ); ?>
					</a>
				</div>
			<?php endif; ?>
		</div>
	</div>
	<?php
	return ob_get_clean();
}

/**
 * Output a Pro upsell overlay card.
 *
 * @since 2.35.0
 *
 * @param array $args See affwp_render_pro_upsell_overlay().
 */
function affwp_pro_upsell_overlay( $args = [] ) {
	echo affwp_render_pro_upsell_overlay( $args ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in the renderer.
}
