<?php
/**
 * Campaign schema and validation.
 *
 * @package VoidPulse
 */

namespace VoidPulse\Shared;

defined( 'ABSPATH' ) || exit;

final class CampaignSchema {
	/**
	 * Return campaign defaults.
	 *
	 * @return array<string,mixed>
	 */
	public static function defaults() {
		return array(
			'name'       => __( 'Recent sales', 'voidpulse-social-proof-for-woocommerce' ),
			'type'       => 'recent_sale',
			'status'     => 'draft',
			'template'   => 'clean',
			'message'    => '{customer} from {location} purchased {product} {time_ago}',
			'identity'   => 'first_name',
			'location'   => 'city',
			'timing'     => array(
				'initial_delay' => 4,
				'duration'      => 6,
				'gap'           => 6,
				'loop'          => true,
				'maximum'       => 10,
				'order'         => 'newest',
				'pause_hidden'  => true,
				'animation'     => 'slide',
				'animation_duration' => 400,
				'animation_easing'   => 'ease-out',
			),
			'style'      => array(
				'background'      => '#ffffff',
				'text'            => '#172033',
				'muted_text'      => '#667085',
				'accent'          => '#6d5dfc',
				'border'          => '#e4e7ec',
				'label_color'     => '#6d5dfc',
				'headline_color'  => '#172033',
				'message_color'   => '#172033',
				'label_size'      => 10,
				'headline_size'   => 15,
				'message_size'    => 14,
				'label_font'      => '',
				'headline_font'   => '',
				'message_font'    => '',
				'label_weight'    => 500,
				'headline_weight' => 500,
				'message_weight'  => 500,
				'radius'          => 14,
				'image_shape'     => 'rounded',
				'font_size'       => 14,
				'width'           => 380,
				'show_image'      => true,
				'show_label'      => false,
				'show_headline'   => false,
				'show_close'      => true,
				'desktop_position'=> 'bottom-left',
				'mobile_position' => 'bottom-left',
			),
			'display'    => array(
				'scope'          => 'storefront',
				'hide_cart'      => true,
				'hide_checkout'  => true,
				'hide_account'   => true,
				'hide_received'  => true,
				'hide_mobile'    => false,
				'hide_desktop'   => false,
			),
			'orders'     => array(
				'days'                   => 30,
				'maximum'                => 50,
				'statuses'               => array( 'wc-processing', 'wc-completed' ),
				'include_guests'         => true,
				'include_hidden_products'=> false,
				'product_rule'           => 'all',
				'product_ids'            => array(),
			),
			'performance' => array(
				'event_cache_size' => 100,
				'show_to_admins'   => true,
				'disable_for_admins'=> false,
				'frequency_cap'     => 0,
				'dismissal_snooze'  => 'session',
				'repeat_protection' => false,
				'cross_page_pacing' => true,
				'optimized_loading' => true,
				'data_saver'        => true,
			),
		);
	}

	/**
	 * Validate and sanitize campaign data.
	 *
	 * @param array<string,mixed> $input Raw input.
	 * @return array<string,mixed>|\WP_Error
	 */
	public static function validate( $input ) {
		if ( ! is_array( $input ) ) {
			return new \WP_Error( 'voidpulse_invalid_campaign', __( 'Campaign data must be an object.', 'voidpulse-social-proof-for-woocommerce' ), array( 'status' => 400 ) );
		}

		$defaults = self::defaults();
		$unknown  = self::unknown_keys( $input, $defaults );
		if ( $unknown ) {
			return new \WP_Error( 'voidpulse_unknown_fields', __( 'The campaign contains unsupported fields.', 'voidpulse-social-proof-for-woocommerce' ), array( 'status' => 400, 'fields' => array_values( $unknown ) ) );
		}

		$has_admin_visibility = isset( $input['performance'] ) && is_array( $input['performance'] ) && array_key_exists( 'show_to_admins', $input['performance'] );
		$has_legacy_visibility = isset( $input['performance'] ) && is_array( $input['performance'] ) && array_key_exists( 'disable_for_admins', $input['performance'] );
		$data             = array_replace_recursive( $defaults, $input );
		$data['name']     = sanitize_text_field( $data['name'] );
		$data['type']     = 'recent_sale';
		$data['status']   = self::allow( $data['status'], array( 'draft', 'active', 'paused' ), 'draft' );
		$data['template'] = self::allow( $data['template'], array( 'clean', 'modern', 'minimal' ), 'clean' );
		$data['identity'] = self::allow( $data['identity'], array( 'first_name', 'first_initial', 'anonymous', 'someone', 'customer' ), 'first_name' );
		$data['location'] = self::allow( $data['location'], array( 'city', 'region', 'country', 'city_country', 'hidden' ), 'city' );

		$tokens = TokenRenderer::validate_message( (string) $data['message'] );
		if ( is_wp_error( $tokens ) ) {
			return $tokens;
		}
		$data['message'] = $tokens;

		$data['timing']['initial_delay'] = self::number( $data['timing']['initial_delay'], 3, 300, 4 );
		$data['timing']['duration']      = self::number( $data['timing']['duration'], 3, 60, 6 );
		$data['timing']['gap']           = self::number( $data['timing']['gap'], 4, 600, 6 );
		$data['timing']['maximum']       = self::number( $data['timing']['maximum'], 1, 20, 10 );
		$data['timing']['loop']          = rest_sanitize_boolean( $data['timing']['loop'] );
		$data['timing']['pause_hidden']  = rest_sanitize_boolean( $data['timing']['pause_hidden'] );
		$data['timing']['order']         = self::allow( $data['timing']['order'], array( 'newest', 'random' ), 'newest' );
		$data['timing']['animation']      = self::allow( $data['timing']['animation'], array( 'fade', 'slide', 'scale', 'bounce', 'flip' ), 'slide' );
		$data['timing']['animation_duration'] = self::number( $data['timing']['animation_duration'], 150, 2000, 400 );
		$data['timing']['animation_easing']   = self::allow( $data['timing']['animation_easing'], array( 'ease-out', 'ease-in-out', 'linear', 'spring' ), 'ease-out' );

		$style_input = ( isset( $input['style'] ) && is_array( $input['style'] ) ) ? $input['style'] : array();
		$has_label_size    = array_key_exists( 'label_size', $style_input );
		$has_headline_size = array_key_exists( 'headline_size', $style_input );
		$has_message_size  = array_key_exists( 'message_size', $style_input );
		$has_font_size     = array_key_exists( 'font_size', $style_input );
		$sizes_absent      = ! $has_label_size && ! $has_headline_size && ! $has_message_size;
		$sticky_default_sizes = $has_label_size && $has_headline_size && $has_message_size
			&& absint( $style_input['label_size'] ) === absint( $defaults['style']['label_size'] )
			&& absint( $style_input['headline_size'] ) === absint( $defaults['style']['headline_size'] )
			&& absint( $style_input['message_size'] ) === absint( $defaults['style']['message_size'] );
		$derive_sizes_from_font = $has_font_size && ( $sizes_absent || $sticky_default_sizes );

		foreach ( array( 'background', 'text', 'muted_text', 'accent', 'border', 'label_color', 'headline_color', 'message_color' ) as $colour ) {
			$data['style'][ $colour ] = sanitize_hex_color( $data['style'][ $colour ] ) ?: $defaults['style'][ $colour ];
		}
		$data['style']['radius']           = self::number( $data['style']['radius'], 0, 32, 14 );
		$data['style']['font_size']        = self::number( $data['style']['font_size'], 12, 20, 14 );
		if ( $derive_sizes_from_font || ! $has_label_size ) {
			$data['style']['label_size'] = max( 8, min( 18, $data['style']['font_size'] - 4 ) );
		} else {
			$data['style']['label_size'] = self::number( $data['style']['label_size'], 8, 18, 10 );
		}
		if ( $derive_sizes_from_font || ! $has_headline_size ) {
			$data['style']['headline_size'] = min( 28, max( 10, $data['style']['font_size'] + 1 ) );
		} else {
			$data['style']['headline_size'] = self::number( $data['style']['headline_size'], 10, 28, 15 );
		}
		if ( $derive_sizes_from_font || ! $has_message_size ) {
			$data['style']['message_size'] = min( 24, max( 10, $data['style']['font_size'] ) );
		} else {
			$data['style']['message_size'] = self::number( $data['style']['message_size'], 10, 24, 14 );
		}
		$data['style']['label_font']       = GoogleFonts::sanitize_font_ref( $data['style']['label_font'] );
		$data['style']['headline_font']    = GoogleFonts::sanitize_font_ref( $data['style']['headline_font'] );
		$data['style']['message_font']     = GoogleFonts::sanitize_font_ref( $data['style']['message_font'] );
		$data['style']['label_weight']     = GoogleFonts::sanitize_weight( $data['style']['label_weight'], 500 );
		$data['style']['headline_weight']  = GoogleFonts::sanitize_weight( $data['style']['headline_weight'], 500 );
		$data['style']['message_weight']   = GoogleFonts::sanitize_weight( $data['style']['message_weight'], 500 );
		$data['style']['width']            = self::number( $data['style']['width'], 280, 520, 380 );
		$data['style']['image_shape']      = self::allow( $data['style']['image_shape'], array( 'square', 'rounded', 'circle' ), 'rounded' );
		$data['style']['desktop_position'] = self::allow( $data['style']['desktop_position'], array( 'bottom-left', 'bottom-right' ), 'bottom-left' );
		$data['style']['mobile_position']  = self::allow( $data['style']['mobile_position'], array( 'bottom-left', 'bottom-right' ), 'bottom-left' );
		foreach ( array( 'show_image', 'show_close', 'show_label', 'show_headline' ) as $boolean ) {
			$data['style'][ $boolean ] = rest_sanitize_boolean( $data['style'][ $boolean ] );
		}

		$data['display']['scope'] = self::allow( $data['display']['scope'], array( 'storefront', 'products', 'archives' ), 'storefront' );
		foreach ( array( 'hide_cart', 'hide_checkout', 'hide_account', 'hide_received', 'hide_mobile', 'hide_desktop' ) as $boolean ) {
			$data['display'][ $boolean ] = rest_sanitize_boolean( $data['display'][ $boolean ] );
		}

		$data['orders']['days']     = self::number( $data['orders']['days'], 1, 365, 30 );
		$data['orders']['maximum']  = self::number( $data['orders']['maximum'], 1, 200, 50 );
		$statuses                   = array_intersect( (array) $data['orders']['statuses'], array( 'wc-processing', 'wc-completed' ) );
		$data['orders']['statuses'] = $statuses ? array_values( array_unique( $statuses ) ) : $defaults['orders']['statuses'];
		$data['orders']['include_guests']          = rest_sanitize_boolean( $data['orders']['include_guests'] );
		$data['orders']['include_hidden_products'] = rest_sanitize_boolean( $data['orders']['include_hidden_products'] );
		$data['orders']['product_rule'] = self::allow( $data['orders']['product_rule'], array( 'all', 'include', 'exclude' ), 'all' );
		$data['orders']['product_ids']  = array_slice(
			array_values(
				array_unique(
					array_filter(
						array_map( 'absint', (array) $data['orders']['product_ids'] )
					)
				)
			),
			0,
			100
		);

		$data['performance']['event_cache_size']  = self::number( $data['performance']['event_cache_size'], 10, 500, 100 );
		$frequency_cap = absint( $data['performance']['frequency_cap'] );
		$data['performance']['frequency_cap'] = 0 === $frequency_cap ? 0 : min( 50, max( 1, $frequency_cap ) );
		$data['performance']['dismissal_snooze']  = self::allow( $data['performance']['dismissal_snooze'], array( 'session', 'day', 'week' ), 'session' );
		foreach ( array( 'repeat_protection', 'cross_page_pacing', 'optimized_loading', 'data_saver' ) as $boolean ) {
			$data['performance'][ $boolean ] = rest_sanitize_boolean( $data['performance'][ $boolean ] );
		}
		if ( ! $has_admin_visibility && $has_legacy_visibility ) {
			$data['performance']['show_to_admins'] = ! rest_sanitize_boolean( $data['performance']['disable_for_admins'] );
		} else {
			$data['performance']['show_to_admins'] = rest_sanitize_boolean( $data['performance']['show_to_admins'] );
		}
		// Keep the former inverse field synchronized so existing exports remain valid.
		$data['performance']['disable_for_admins'] = ! $data['performance']['show_to_admins'];

		return $data;
	}

	/**
	 * Remove stored record metadata so a round-tripped campaign validates.
	 *
	 * @param mixed $input Raw input.
	 * @return mixed
	 */
	public static function strip_metadata( $input ) {
		if ( ! is_array( $input ) ) {
			return $input;
		}
		foreach ( array( 'id', 'created_by', 'created_at', 'updated_at', 'published_at', 'analytics', 'webhook_secret' ) as $key ) {
			unset( $input[ $key ] );
		}
		return $input;
	}

	/**
	 * Sanitize allowlisted value.
	 *
	 * @param mixed         $value Value.
	 * @param array<string> $allowed Allowed.
	 * @param string        $fallback Fallback.
	 * @return string
	 */
	private static function allow( $value, $allowed, $fallback ) {
		$value = sanitize_key( (string) $value );
		return in_array( $value, $allowed, true ) ? $value : $fallback;
	}

	/**
	 * Bound an integer.
	 *
	 * @param mixed $value Value.
	 * @param int   $minimum Minimum.
	 * @param int   $maximum Maximum.
	 * @param int   $fallback Fallback.
	 * @return int
	 */
	private static function number( $value, $minimum, $maximum, $fallback ) {
		if ( ! is_numeric( $value ) ) {
			return $fallback;
		}
		return min( $maximum, max( $minimum, absint( $value ) ) );
	}

	/**
	 * Find unknown keys recursively while allowing list values.
	 *
	 * @param array<mixed>  $input Input.
	 * @param array<mixed>  $schema Default schema.
	 * @param string        $prefix Dot path.
	 * @return array<string>
	 */
	private static function unknown_keys( $input, $schema, $prefix = '' ) {
		$schema_keys = array_keys( $schema );
		$is_list     = array() === $schema_keys || $schema_keys === range( 0, count( $schema_keys ) - 1 );
		if ( $is_list ) {
			return array();
		}
		$unknown = array();
		foreach ( $input as $key => $value ) {
			$path = $prefix ? $prefix . '.' . $key : (string) $key;
			if ( ! array_key_exists( $key, $schema ) ) {
				$unknown[] = $path;
				continue;
			}
			if ( is_array( $value ) && is_array( $schema[ $key ] ) ) {
				$unknown = array_merge( $unknown, self::unknown_keys( $value, $schema[ $key ], $path ) );
			}
		}
		return $unknown;
	}
}
