<?php
/**
 * Per-Brevo-type value coercion and validation.
 *
 * @package RewindBrevoFormBridge
 */

namespace RewindBrevoFormBridge\Mapping;

defined( 'ABSPATH' ) || exit;

/**
 * The most important class in the plugin: Brevo silently discards values
 * that do not match an attribute's declared type — no error, just an empty
 * field. Everything is coerced and validated here BEFORE it is sent, and
 * every rejection carries a reason fit for the delivery log.
 *
 * Pure PHP (no WP calls except translation) so it is unit-testable.
 *
 * @since 0.2.0
 */
class Coercer {

	/**
	 * Coerce a submitted value to a Brevo attribute type.
	 *
	 * Context keys:
	 * - required     bool    empty + required = rejection (text only).
	 * - date_format  string  per-mapping hint, 'd/m/Y' or 'm/d/Y'.
	 * - site_date_format string the site's date_format option, fallback hint.
	 * - enumeration  array   [ ['value' => int, 'label' => string], ... ]
	 * - multi_options array  string[] of allowed multiple-choice options.
	 *
	 * @since 0.2.0
	 * @param mixed  $value      Raw submitted value.
	 * @param string $brevo_type text|float|date|boolean|category|multiple-choice|id.
	 * @param array  $context    See above.
	 * @return array ['ok' => bool, 'value' => mixed, 'reason' => string].
	 */
	public function coerce( $value, string $brevo_type, array $context = array() ): array {
		switch ( $brevo_type ) {
			case 'text':
				return $this->to_text( $value, ! empty( $context['required'] ) );
			case 'float':
				return $this->to_float( $value );
			case 'date':
				return $this->to_date( $value, $context );
			case 'boolean':
				return $this->to_boolean( $value );
			case 'category':
				return $this->to_category( $value, isset( $context['enumeration'] ) && is_array( $context['enumeration'] ) ? $context['enumeration'] : array() );
			case 'multiple-choice':
				return $this->to_multiple_choice( $value, isset( $context['multi_options'] ) && is_array( $context['multi_options'] ) ? $context['multi_options'] : array() );
			case 'id':
				return $this->to_id( $value );
		}

		return $this->reject(
			sprintf(
				/* translators: %s: the unknown Brevo attribute type. */
				__( 'Unknown Brevo attribute type "%s".', 'rewind-form-bridge-for-brevo' ),
				$brevo_type
			)
		);
	}

	/**
	 * text: string cast, tags stripped, whitespace collapsed. Arrays
	 * (checkbox groups) join with ", ".
	 *
	 * @since 0.2.0
	 * @param mixed $value    Raw value.
	 * @param bool  $required Whether the mapping is marked required.
	 * @return array Result triple.
	 */
	private function to_text( $value, bool $required ): array {
		if ( is_array( $value ) ) {
			$flat  = array();
			array_walk_recursive(
				$value,
				function ( $item ) use ( &$flat ) {
					$flat[] = (string) $item;
				}
			);
			$value = implode( ', ', $flat );
		}

		$text = (string) $value;
		$text = strip_tags( $text ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- pure-PHP on purpose: unit tests run without WordPress.
		$text = preg_replace( '/\s+/u', ' ', $text );
		$text = trim( $text );

		if ( '' === $text && $required ) {
			return $this->reject( __( 'The field was empty but the mapping is marked required.', 'rewind-form-bridge-for-brevo' ) );
		}

		return $this->accept( $text );
	}

	/**
	 * float: tolerant of currency symbols, thousands separators and comma
	 * decimals. Returns a real float.
	 *
	 * @since 0.2.0
	 * @param mixed $value Raw value.
	 * @return array Result triple.
	 */
	private function to_float( $value ): array {
		if ( is_int( $value ) || is_float( $value ) ) {
			return $this->accept( (float) $value );
		}

		$original = is_scalar( $value ) ? (string) $value : '';
		// Keep digits, separators and sign; drop currency symbols, %, letters
		// and every kind of space (incl. non-breaking, which "1 234,56" uses).
		$clean = preg_replace( '/[^\d,.\-]/u', '', str_replace( "\xc2\xa0", '', $original ) );

		if ( '' === $clean || null === $clean ) {
			return $this->reject(
				sprintf(
					/* translators: %s: the rejected raw value. */
					__( 'Could not read "%s" as a number.', 'rewind-form-bridge-for-brevo' ),
					$this->excerpt( $original )
				)
			);
		}

		$has_comma = false !== strpos( $clean, ',' );
		$has_dot   = false !== strpos( $clean, '.' );

		if ( $has_comma && $has_dot ) {
			// Whichever separator appears LAST is the decimal separator.
			if ( strrpos( $clean, ',' ) > strrpos( $clean, '.' ) ) {
				$clean = str_replace( '.', '', $clean );
				$clean = str_replace( ',', '.', $clean );
			} else {
				$clean = str_replace( ',', '', $clean );
			}
		} elseif ( $has_comma ) {
			// "1,234" / "1,234,567" are thousands; "1234,56" is a decimal.
			if ( preg_match( '/^-?\d{1,3}(,\d{3})+$/', $clean ) ) {
				$clean = str_replace( ',', '', $clean );
			} else {
				$clean = str_replace( ',', '.', $clean );
			}
		}

		if ( ! is_numeric( $clean ) ) {
			return $this->reject(
				sprintf(
					/* translators: %s: the rejected raw value. */
					__( 'Could not read "%s" as a number.', 'rewind-form-bridge-for-brevo' ),
					$this->excerpt( $original )
				)
			);
		}

		return $this->accept( (float) $clean );
	}

	/**
	 * date: ISO, day-first, month-first, textual months and Unix timestamps
	 * all accepted; impossible dates rejected (never rolled over). Output is
	 * strictly Y-m-d.
	 *
	 * @since 0.2.0
	 * @param mixed $value   Raw value.
	 * @param array $context date_format / site_date_format hints.
	 * @return array Result triple.
	 */
	private function to_date( $value, array $context ): array {
		$raw = is_scalar( $value ) ? trim( (string) $value ) : '';

		if ( '' === $raw ) {
			return $this->reject( __( 'The date field was empty.', 'rewind-form-bridge-for-brevo' ) );
		}

		// Unix timestamp (all digits, not an 8-digit Ymd or 4-digit year).
		if ( preg_match( '/^\d+$/', $raw ) ) {
			if ( 8 === strlen( $raw ) ) {
				$y = (int) substr( $raw, 0, 4 );
				$m = (int) substr( $raw, 4, 2 );
				$d = (int) substr( $raw, 6, 2 );
				if ( checkdate( $m, $d, $y ) ) {
					return $this->accept( sprintf( '%04d-%02d-%02d', $y, $m, $d ) );
				}
			}
			if ( strlen( $raw ) >= 9 ) {
				return $this->accept( gmdate( 'Y-m-d', (int) $raw ) );
			}
		}

		// ISO Y-m-d (optionally with a time part we ignore).
		if ( preg_match( '/^(\d{4})-(\d{2})-(\d{2})([T ].*)?$/', $raw, $m ) ) {
			return $this->check_ymd( (int) $m[1], (int) $m[2], (int) $m[3], $raw );
		}

		// Y/m/d or Y.m.d.
		if ( preg_match( '/^(\d{4})[\/.](\d{1,2})[\/.](\d{1,2})$/', $raw, $m ) ) {
			return $this->check_ymd( (int) $m[1], (int) $m[2], (int) $m[3], $raw );
		}

		// Two-part-first formats: d/m/Y, m/d/Y, d-m-Y, m-d-Y, d.m.Y.
		if ( preg_match( '/^(\d{1,2})[\/\-.](\d{1,2})[\/\-.](\d{4})$/', $raw, $m ) ) {
			return $this->parse_ambiguous( (int) $m[1], (int) $m[2], (int) $m[3], $raw, $context );
		}

		// Textual months ("24 August 2026", "Aug 24, 2026"). date_parse()
		// reports impossible dates via warning_count instead of rolling them
		// over the way strtotime() would.
		if ( preg_match( '/[a-z]/i', $raw ) ) {
			$parsed = date_parse( $raw );

			if ( is_array( $parsed )
				&& 0 === (int) $parsed['error_count']
				&& 0 === (int) $parsed['warning_count']
				&& false !== $parsed['year'] && false !== $parsed['month'] && false !== $parsed['day']
			) {
				return $this->check_ymd( (int) $parsed['year'], (int) $parsed['month'], (int) $parsed['day'], $raw );
			}
		}

		return $this->reject(
			sprintf(
				/* translators: %s: the rejected raw value. */
				__( 'Could not read "%s" as a date.', 'rewind-form-bridge-for-brevo' ),
				$this->excerpt( $raw )
			)
		);
	}

	/**
	 * Resolve an ambiguous two-part date using the mapping's date_format
	 * hint (falling back to the site's date_format), recording the assumption
	 * in the reason string.
	 *
	 * @since 0.2.0
	 * @param int    $first   First number in the string.
	 * @param int    $second  Second number.
	 * @param int    $year    Four-digit year.
	 * @param string $raw     Original value, for messages.
	 * @param array  $context Format hints.
	 * @return array Result triple.
	 */
	private function parse_ambiguous( int $first, int $second, int $year, string $raw, array $context ): array {
		$hint = '';
		if ( isset( $context['date_format'] ) && is_string( $context['date_format'] ) ) {
			$hint = $context['date_format'];
		} elseif ( isset( $context['site_date_format'] ) && is_string( $context['site_date_format'] ) ) {
			$hint = $context['site_date_format'];
		}

		// Day-first unless the hint clearly puts the month token before the
		// day token (e.g. WordPress' default "F j, Y", or "m/d/Y").
		$day_first = true;
		$m_pos     = $this->first_pos( $hint, array( 'm', 'n', 'F', 'M' ) );
		$d_pos     = $this->first_pos( $hint, array( 'd', 'j' ) );
		if ( null !== $m_pos && null !== $d_pos ) {
			$day_first = $d_pos < $m_pos;
		}

		$order   = $day_first ? array( $first, $second ) : array( $second, $first );
		$assumed = $day_first ? 'd/m/Y' : 'm/d/Y';

		if ( checkdate( $order[1], $order[0], $year ) ) {
			return $this->accept(
				sprintf( '%04d-%02d-%02d', $year, $order[1], $order[0] ),
				sprintf(
					/* translators: %s: the day/month order that was assumed. */
					__( 'Assumed %s order for an ambiguous date.', 'rewind-form-bridge-for-brevo' ),
					$assumed
				)
			);
		}

		// The preferred order was impossible (e.g. month 13) — the other
		// order disambiguates it unambiguously.
		$flipped = array( $order[1], $order[0] );
		if ( checkdate( $flipped[1], $flipped[0], $year ) ) {
			return $this->accept( sprintf( '%04d-%02d-%02d', $year, $flipped[1], $flipped[0] ) );
		}

		return $this->reject(
			sprintf(
				/* translators: %s: the rejected raw value. */
				__( 'Could not read "%s" as a date — no valid day/month combination.', 'rewind-form-bridge-for-brevo' ),
				$this->excerpt( $raw )
			)
		);
	}

	/**
	 * boolean: fixed truthy/falsy vocabularies, then "any non-empty checkbox
	 * value is true". Always succeeds with a real bool.
	 *
	 * @since 0.2.0
	 * @param mixed $value Raw value.
	 * @return array Result triple.
	 */
	private function to_boolean( $value ): array {
		if ( is_bool( $value ) ) {
			return $this->accept( $value );
		}

		if ( is_array( $value ) ) {
			return $this->accept( count( array_filter( $value, 'strlen' ) ) > 0 );
		}

		$norm = strtolower( trim( (string) $value ) );

		if ( in_array( $norm, array( '0', 'false', 'no', 'off', '' ), true ) ) {
			return $this->accept( false );
		}

		// '1', 'true', 'yes', 'on', 'y' and any other non-empty checkbox value.
		return $this->accept( true );
	}

	/**
	 * category: match the value against the attribute's enumeration labels
	 * (case-insensitive, trimmed) and return the numeric enumeration id,
	 * which is what POST /contacts expects for category attributes.
	 *
	 * @since 0.2.0
	 * @param mixed $value       Raw value.
	 * @param array $enumeration [ ['value' => int, 'label' => string], ... ].
	 * @return array Result triple.
	 */
	private function to_category( $value, array $enumeration ): array {
		$needle = strtolower( trim( is_scalar( $value ) ? (string) $value : '' ) );
		$labels = array();

		foreach ( $enumeration as $option ) {
			$label    = isset( $option['label'] ) ? (string) $option['label'] : '';
			$labels[] = $label;

			if ( strtolower( trim( $label ) ) === $needle && '' !== $needle ) {
				return $this->accept( isset( $option['value'] ) ? (int) $option['value'] : 0 );
			}
		}

		return $this->reject(
			sprintf(
				/* translators: 1: the rejected value, 2: the allowed labels. */
				__( '"%1$s" is not one of the allowed values: %2$s.', 'rewind-form-bridge-for-brevo' ),
				$this->excerpt( is_scalar( $value ) ? (string) $value : gettype( $value ) ),
				implode( ', ', $labels )
			)
		);
	}

	/**
	 * multiple-choice: validate each member against the allowed options
	 * (case-insensitive), keep the canonical casing, drop invalid members
	 * (reported in the reason), reject only when nothing survives.
	 *
	 * @since 0.2.0
	 * @param mixed $value   Raw value (scalar becomes a one-item array).
	 * @param array $options Allowed option strings.
	 * @return array Result triple.
	 */
	private function to_multiple_choice( $value, array $options ): array {
		$values = is_array( $value ) ? $value : array( $value );
		$lookup = array();

		foreach ( $options as $option ) {
			$lookup[ strtolower( trim( (string) $option ) ) ] = (string) $option;
		}

		$kept    = array();
		$dropped = array();

		foreach ( $values as $member ) {
			$needle = strtolower( trim( is_scalar( $member ) ? (string) $member : '' ) );

			if ( '' === $needle ) {
				continue;
			}

			if ( isset( $lookup[ $needle ] ) ) {
				$kept[] = $lookup[ $needle ];
			} else {
				$dropped[] = (string) $member;
			}
		}

		$kept = array_values( array_unique( $kept ) );

		if ( 0 === count( $kept ) ) {
			return $this->reject(
				0 === count( $dropped )
					? __( 'No values were submitted for this multiple-choice field.', 'rewind-form-bridge-for-brevo' )
					: sprintf(
						/* translators: 1: the rejected values, 2: the allowed options. */
						__( 'None of the submitted values (%1$s) match the allowed options: %2$s.', 'rewind-form-bridge-for-brevo' ),
						implode( ', ', $dropped ),
						implode( ', ', $options )
					)
			);
		}

		$note = '';
		if ( count( $dropped ) > 0 ) {
			$note = sprintf(
				/* translators: %s: the values that were dropped. */
				__( 'Dropped values not in the allowed options: %s.', 'rewind-form-bridge-for-brevo' ),
				implode( ', ', $dropped )
			);
		}

		return $this->accept( $kept, $note );
	}

	/**
	 * id: strict integer.
	 *
	 * @since 0.2.0
	 * @param mixed $value Raw value.
	 * @return array Result triple.
	 */
	private function to_id( $value ): array {
		if ( is_int( $value ) ) {
			return $this->accept( $value );
		}

		$raw = is_scalar( $value ) ? trim( (string) $value ) : '';

		if ( preg_match( '/^-?\d+$/', $raw ) ) {
			return $this->accept( (int) $raw );
		}

		return $this->reject(
			sprintf(
				/* translators: %s: the rejected raw value. */
				__( 'Could not read "%s" as a whole number (id).', 'rewind-form-bridge-for-brevo' ),
				$this->excerpt( $raw )
			)
		);
	}

	/**
	 * Validate a Y/m/d triple with checkdate — never roll impossible dates.
	 *
	 * @param int    $y   Year.
	 * @param int    $m   Month.
	 * @param int    $d   Day.
	 * @param string $raw Original value, for the rejection message.
	 * @return array Result triple.
	 */
	private function check_ymd( int $y, int $m, int $d, string $raw ): array {
		if ( checkdate( $m, $d, $y ) ) {
			return $this->accept( sprintf( '%04d-%02d-%02d', $y, $m, $d ) );
		}

		return $this->reject(
			sprintf(
				/* translators: %s: the rejected raw value. */
				__( 'Could not read "%s" as a date — that day does not exist.', 'rewind-form-bridge-for-brevo' ),
				$this->excerpt( $raw )
			)
		);
	}

	/**
	 * Position of the first occurrence of any of the given tokens.
	 *
	 * @param string   $haystack Format string.
	 * @param string[] $tokens   Characters to look for.
	 * @return int|null
	 */
	private function first_pos( string $haystack, array $tokens ): ?int {
		$best = null;
		foreach ( $tokens as $token ) {
			$pos = strpos( $haystack, $token );
			if ( false !== $pos && ( null === $best || $pos < $best ) ) {
				$best = $pos;
			}
		}

		return $best;
	}

	/**
	 * Success triple.
	 *
	 * @param mixed  $value  Coerced value.
	 * @param string $reason Optional note (assumption, dropped members).
	 * @return array
	 */
	private function accept( $value, string $reason = '' ): array {
		return array(
			'ok'     => true,
			'value'  => $value,
			'reason' => $reason,
		);
	}

	/**
	 * Rejection triple.
	 *
	 * @param string $reason Log-worthy explanation.
	 * @return array
	 */
	private function reject( string $reason ): array {
		return array(
			'ok'     => false,
			'value'  => null,
			'reason' => $reason,
		);
	}

	/**
	 * Shorten a raw value for use inside a message.
	 *
	 * @param string $value Raw value.
	 * @return string
	 */
	private function excerpt( string $value ): string {
		return strlen( $value ) > 60 ? substr( $value, 0, 57 ) . '...' : $value;
	}
}
