<?php
/**
 * Notices trait
 *
 * @package     AffiliateWP
 * @subpackage  Utils\Traits
 * @copyright   Copyright (c) 2026, Sandhills Development, LLC
 * @license     http://opensource.org/licenses/gpl-2.0.php GNU Public License
 * @since       2.33.0
 */

namespace AffiliateWP\Utils\Traits;

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

/**
 * Notices trait
 *
 * Renders the canonical inline notice block used across admin screens:
 * a flex icon+content layout on a tinted rounded card. Variants pick the
 * icon and color palette (tip = lightbulb on yellow, warning = triangle
 * on yellow, etc.). Body and actions are slot-style — pass raw HTML so
 * callers can embed Alpine bindings, links, or other dynamic content.
 *
 * @since 2.33.0
 */
trait Notices {

	/**
	 * Notice variants. Maps the variant key to its palette (background,
	 * border, icon color, heading color, body color) and the Nucleo icon
	 * name + style that anchors the row.
	 *
	 * @since 2.33.0
	 * @var array
	 */
	protected $notice_variants = [
		'tip'     => [
			'bg'            => 'bg-yellow-50',
			'border'        => 'border-yellow-200',
			'icon'          => 'lightbulb',
			'icon_style'    => 'outline-duo',
			'icon_color'    => 'text-yellow-700',
			'heading'       => 'text-yellow-900',
			'body'          => 'text-yellow-700',
			'dismiss_hover' => 'hover:bg-yellow-900/5 hover:text-yellow-900',
		],
		'warning' => [
			'bg'            => 'bg-yellow-50',
			'border'        => 'border-yellow-200',
			'icon'          => 'triangle-warning',
			'icon_style'    => 'outline-duo',
			'icon_color'    => 'text-yellow-700',
			'heading'       => 'text-yellow-900',
			'body'          => 'text-yellow-700',
			'dismiss_hover' => 'hover:bg-yellow-900/5 hover:text-yellow-900',
		],
		/*
		 * Warning severity on a neutral surface: a persistent, blocking condition
		 * (e.g. gated schedules that have stopped running) that must register as
		 * "needs attention" without the full yellow-fill alarm of `warning`. The
		 * triangle keeps its warning tint so the state still signals; the white
		 * surface lets it read as a card beside the neutral admin UI.
		 */
		'warning-soft' => [
			'bg'            => 'bg-white',
			'border'        => 'border-gray-200',
			'icon'          => 'triangle-warning',
			'icon_style'    => 'outline-duo',
			'icon_color'    => 'text-yellow-600',
			'heading'       => 'text-gray-900',
			'body'          => 'text-gray-600',
			'dismiss_color' => 'text-gray-400',
			'dismiss_hover' => 'hover:bg-gray-100 hover:text-gray-700',
		],
		'feature' => [
			'bg'            => 'bg-white',
			'border'        => 'border-gray-200',
			'icon'          => '',
			'icon_style'    => 'outline',
			'icon_color'    => 'text-affwp-brand-600',
			'heading'       => 'text-gray-900',
			'body'          => 'text-gray-600',
			'dismiss_color' => 'text-gray-400',
			'dismiss_hover' => 'hover:bg-gray-100 hover:text-gray-700',
		],
		// Quiet, neutral notice for low-stakes/recoverable states (e.g. an AI
		// license/credit nudge) — no alarm color, so it sits inside the neutral
		// card UI without competing with primary actions. Accent stays on the
		// action button, not the surface.
		'info'    => [
			'bg'         => 'bg-gray-50',
			'border'     => 'border-gray-200',
			'icon'       => 'circle-info',
			'icon_style' => 'outline',
			'icon_color' => 'text-gray-400',
			'heading'    => 'text-gray-900',
			'body'       => 'text-gray-600',
		],
	];

	/**
	 * Render a notice component.
	 *
	 * @since 2.33.0
	 *
	 * @param array $args {
	 *     Notice arguments.
	 *
	 *     @type string $variant       Variant key. 'tip', 'warning', or 'feature'. Default 'warning'.
	 *     @type string $heading       Heading. Rendered raw — callers must escape plain text
	 *                                  themselves (esc_html__()) and may embed Alpine bindings.
	 *     @type string $body          Body. Rendered raw — same contract as heading. Required
	 *                                  if heading is empty; either may be omitted otherwise.
	 *     @type string $actions       Optional actions row HTML. Rendered raw below body.
	 *     @type bool   $dismissable   Whether to render the dismiss button. Default false.
	 *     @type array  $dismiss_attrs Raw HTML attrs for the dismiss button (e.g. @click handler).
	 *     @type array  $attributes    Raw HTML attrs for the outer wrapper (x-show, x-cloak, id, etc.).
	 *     @type string $class         Additional classes for the outer wrapper.
	 * }
	 * @return string The rendered notice HTML.
	 */
	public function render_notice( $args = [] ) {
		$defaults = [
			'variant'       => 'warning',
			'heading'       => '',
			'body'          => '',
			'actions'       => '',
			'dismissable'   => false,
			'dismiss_attrs' => [],
			'attributes'    => [],
			'class'         => '',
		];

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

		if ( '' === $args['body'] && '' === $args['heading'] ) {
			return '';
		}

		$variant = isset( $this->notice_variants[ $args['variant'] ] )
			? $this->notice_variants[ $args['variant'] ]
			: $this->notice_variants['warning'];

		$wrapper_classes = trim(
			sprintf(
				'relative rounded-lg border p-4 %s %s %s',
				$variant['bg'],
				$variant['border'],
				$args['class']
			)
		);

		/*
		 * Visibility via the CSS grid-rows trick. When the caller passes
		 * `x-show` (reactive visibility), we extract the expression and
		 * wrap the notice in a grid container that animates
		 * `grid-template-rows` between `1fr` (visible) and `0fr`
		 * (collapsed). Grid computes the real content height at runtime,
		 * so there's no fixed max-height to tune and the surrounding
		 * layout slides smoothly. `display: none` is never set on the
		 * wrapper, which is what kept the previous version janky —
		 * Alpine's `x-show` snaps the wrapper out of layout the instant
		 * the fade ends. `x-cloak`, if present, moves to the grid
		 * container so the initial render doesn't flash content while
		 * Alpine boots.
		 */
		$show_expression = '';
		$has_cloak       = false;
		if ( isset( $args['attributes']['x-show'] ) ) {
			$show_expression = $args['attributes']['x-show'];
			unset( $args['attributes']['x-show'] );
		}
		if ( isset( $args['attributes']['x-cloak'] ) ) {
			$has_cloak = true;
			unset( $args['attributes']['x-cloak'] );
		}

		$wrapper_attributes          = $this->build_notice_attributes( $args['attributes'] );
		$wrapper_attributes['class'] = esc_attr( $wrapper_classes );

		$wrapper_attributes_string = $this->notice_attributes_to_string( $wrapper_attributes );

		ob_start();

		if ( '' !== $show_expression ) :
			$class_binding = $show_expression . " ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'";
			?>
			<div
				class="grid transition-all duration-200 ease-in-out"
				:class="<?php echo esc_attr( $class_binding ); ?>"
				<?php echo $has_cloak ? 'x-cloak' : ''; ?>
			>
				<div class="overflow-hidden">
		<?php endif; ?>

		<div <?php echo $wrapper_attributes_string; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- attributes are pre-escaped: non-Alpine values via esc_attr() in build_notice_attributes(), class via esc_attr() above; Alpine keys (x-*, @*, :*) are intentionally raw to preserve directives that wp_kses would strip. ?>>
			<?php if ( $args['dismissable'] ) : ?>
				<?php echo $this->render_notice_dismiss_button( $variant, $args['dismiss_attrs'] ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- render_notice_dismiss_button() returns pre-escaped HTML (esc_attr() on all values via build_notice_attributes() + class). ?>
			<?php endif; ?>
			<div class="flex">
				<?php if ( '' !== $variant['icon'] ) : ?>
					<div class="shrink-0 <?php echo esc_attr( $variant['icon_color'] ); ?>">
						<?php
						\AffiliateWP\Utils\Icons::render(
							$variant['icon'],
							'',
							[
								'class' => 'h-5 w-5',
								'style' => $variant['icon_style'],
							]
						);
						?>
					</div>
				<?php endif; ?>
				<div class="<?php echo '' !== $variant['icon'] ? 'ml-3 ' : ''; ?><?php echo $args['dismissable'] ? 'pr-6' : ''; ?>">
					<?php if ( '' !== $args['heading'] ) : ?>
						<p class="text-sm font-medium <?php echo esc_attr( $variant['heading'] ); ?>">
							<?php
							/*
							 * Slot output: callers escape plain text and construct safe HTML.
							 * wp_kses_post would strip Alpine directives (x-text, x-for, @click) and break reactive notices.
							 */
							echo $args['heading']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
							?>
						</p>
					<?php endif; ?>
					<?php if ( '' !== $args['body'] ) : ?>
						<p class="<?php echo '' !== $args['heading'] ? 'mt-1' : ''; ?> text-sm <?php echo esc_attr( $variant['body'] ); ?>">
							<?php echo $args['body']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- slot output. ?>
						</p>
					<?php endif; ?>
					<?php if ( '' !== $args['actions'] ) : ?>
						<div class="mt-2 text-sm">
							<?php echo $args['actions']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- slot output. ?>
						</div>
					<?php endif; ?>
				</div>
			</div>
		</div>
		<?php if ( '' !== $show_expression ) : ?>
				</div>
			</div>
		<?php endif; ?>
		<?php
		return ob_get_clean();
	}

	/**
	 * Render the dismiss button used in dismissable notices.
	 *
	 * @since 2.33.0
	 *
	 * @param array $variant       Resolved variant palette.
	 * @param array $dismiss_attrs Raw HTML attributes for the button.
	 * @return string Button HTML.
	 */
	protected function render_notice_dismiss_button( $variant, $dismiss_attrs ) {
		$attributes = $this->build_notice_attributes( $dismiss_attrs );

		$dismiss_hover = isset( $variant['dismiss_hover'] ) ? $variant['dismiss_hover'] : 'hover:bg-gray-900/5';
		$dismiss_color = isset( $variant['dismiss_color'] ) ? $variant['dismiss_color'] : $variant['icon_color'];

		$attributes['type']       = 'button';
		$attributes['aria-label'] = isset( $attributes['aria-label'] )
			? $attributes['aria-label']
			: esc_attr__( 'Dismiss', 'affiliate-wp' );
		$attributes['class']      = esc_attr(
			trim(
				sprintf(
					'absolute top-2 right-2 inline-flex items-center justify-center w-6 h-6 rounded cursor-pointer %s %s',
					$dismiss_color,
					$dismiss_hover
				)
			)
		);

		$attributes_string = $this->notice_attributes_to_string( $attributes );

		ob_start();
		?>
		<button <?php echo $attributes_string; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- attributes are pre-escaped: dismiss_attrs values via esc_attr() in build_notice_attributes(), aria-label via esc_attr__(), class via esc_attr() above; Alpine keys are intentionally raw. ?>>
			<?php
			\AffiliateWP\Utils\Icons::render(
				'x-mark',
				'',
				[ 'class' => 'h-3.5 w-3.5' ]
			);
			?>
		</button>
		<?php
		return ob_get_clean();
	}

	/**
	 * Build an attribute array, preserving Alpine directives unescaped and
	 * escaping plain attribute values.
	 *
	 * @since 2.33.0
	 *
	 * @param array $attributes Raw attribute pairs.
	 * @return array Attribute array suitable for notice_attributes_to_string().
	 */
	protected function build_notice_attributes( $attributes ) {
		$built = [];

		if ( ! is_array( $attributes ) ) {
			return $built;
		}

		foreach ( $attributes as $key => $value ) {
			if ( strpos( $key, '@' ) === 0 || strpos( $key, 'x-' ) === 0 || strpos( $key, ':' ) === 0 ) {
				$built[ $key ] = $value;
			} else {
				$built[ $key ] = esc_attr( $value );
			}
		}

		return $built;
	}

	/**
	 * Convert an attribute array into an HTML attribute string. Boolean
	 * attributes (empty string or true) render bare (no `=""`), which is
	 * what Alpine wants for `x-cloak`.
	 *
	 * @since 2.33.0
	 *
	 * @param array $attributes Attribute pairs.
	 * @return string Attribute string.
	 */
	protected function notice_attributes_to_string( $attributes ) {
		$pieces = [];
		foreach ( $attributes as $key => $value ) {
			if ( '' === $value || true === $value ) {
				$pieces[] = $key;
			} else {
				$pieces[] = sprintf( '%s="%s"', $key, $value );
			}
		}
		return implode( ' ', $pieces );
	}
}
