<?php
/**
 * Canonical value normalization.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Field;

use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Produces stable values for comparison, persistence, and checksums.
 */
final class CanonicalNormalizer {
	/**
	 * @param mixed               $value Raw value.
	 * @param array<string,mixed> $options Field-specific normalization options.
	 * @return mixed
	 */
	public function normalize( string $type, $value, array $options = array() ) {
		if ( null === $value ) {
			return null;
		}

		if ( '' === $value && (bool) ( $options['empty_is_null'] ?? false ) ) {
			return null;
		}

		if (
			(bool) ( $options['zero_is_null'] ?? false )
			&& in_array( $value, array( 0, '0', '' ), true )
		) {
			return null;
		}

		switch ( $type ) {
			case FieldDataType::STRING:
				return $this->normalizeString( $value, $options );
			case FieldDataType::INTEGER:
			case FieldDataType::ID:
				return $this->normalizeInteger( $value );
			case FieldDataType::DECIMAL:
				return $this->normalizeDecimal( $value, $options );
			case FieldDataType::BOOLEAN:
				return $this->normalizeBoolean( $value );
			case FieldDataType::DATE:
				return $this->normalizeDate( $value );
			case FieldDataType::DATETIME:
				return $this->normalizeDateTime( $value );
			case FieldDataType::ID_LIST:
				return $this->normalizeList( $value, FieldDataType::ID, $options );
			case FieldDataType::STRING_LIST:
				return $this->normalizeList( $value, FieldDataType::STRING, $options );
			case FieldDataType::HTML:
				return $this->normalizeHtml( $value );
			case FieldDataType::OBJECT:
				return $this->normalizeObject( $value );
			case FieldDataType::ARRAY:
				return $this->normalizeArray( $value );
			case FieldDataType::MIXED:
				return $this->normalizeMixed( $value );
		}
	}

	/**
	 * Compares two values after field-aware normalization.
	 *
	 * @param mixed $left
	 * @param mixed $right
	 */
	public function equivalent( FieldDefinition $field, $left, $right ): bool {
		return $this->normalize( $field->dataType(), $left, $field->normalization() )
			=== $this->normalize( $field->dataType(), $right, $field->normalization() );
	}

	/**
	 * @param array<string,mixed> $options Normalization options.
	 * @param mixed               $value
	 */
	private function normalizeString( $value, array $options ): ?string {
		$string = is_scalar( $value ) || ( is_object( $value ) && method_exists( $value, '__toString' ) )
			? (string) $value
			: null;

		if ( null === $string ) {
			throw new InvalidArgumentException( 'Expected a scalar string value.' );
		}

		$string = str_replace( array( "\r\n", "\r" ), "\n", $string );

		if ( (bool) ( $options['trim'] ?? false ) ) {
			$string = trim( $string );
		}

		if ( '' === $string && (bool) ( $options['empty_is_null'] ?? false ) ) {
			return null;
		}

		return $string;
	}

	/**
	 * @param mixed $value
	 */
	private function normalizeInteger( $value ): int {
		if ( is_int( $value ) ) {
			return $value;
		}

		if ( is_numeric( $value ) && (float) (int) $value === (float) $value ) {
			return (int) $value;
		}

		throw new InvalidArgumentException( 'Expected an integer-compatible value.' );
	}

	/**
	 * @param array<string,mixed> $options Normalization options.
	 * @param mixed               $value
	 */
	private function normalizeDecimal( $value, array $options ): string {
		if ( ! is_numeric( $value ) ) {
			throw new InvalidArgumentException( 'Expected a numeric decimal value.' );
		}

		$precision = max( 0, (int) ( $options['precision'] ?? 6 ) );
		$decimal   = number_format( (float) $value, $precision, '.', '' );

		$decimal = rtrim( rtrim( $decimal, '0' ), '.' );

		return '' === $decimal || '-0' === $decimal ? '0' : $decimal;
	}

	/**
	 * @param mixed $value
	 */
	private function normalizeBoolean( $value ): bool {
		if ( is_bool( $value ) ) {
			return $value;
		}

		if ( in_array( $value, array( 1, '1', 'yes', 'true', 'on' ), true ) ) {
			return true;
		}

		if ( in_array( $value, array( 0, '0', 'no', 'false', 'off', '' ), true ) ) {
			return false;
		}

		throw new InvalidArgumentException( 'Expected a boolean-compatible value.' );
	}

	/**
	 * @param mixed $value
	 */
	private function normalizeDate( $value ): string {
		$date = $this->dateTime( $value );

		return $date->format( 'Y-m-d' );
	}

	/**
	 * @param mixed $value
	 */
	private function normalizeDateTime( $value ): string {
		$date = $this->dateTime( $value )->setTimezone( new DateTimeZone( 'UTC' ) );

		return $date->format( 'Y-m-d\TH:i:s\Z' );
	}

	/**
	 * @param mixed $value
	 */
	private function dateTime( $value ): DateTimeImmutable {
		if ( $value instanceof DateTimeImmutable ) {
			return $value;
		}

		if ( $value instanceof DateTimeInterface ) {
			return new DateTimeImmutable( $value->format( DateTimeInterface::ATOM ) );
		}

		if ( is_string( $value ) && '' !== trim( $value ) ) {
			return new DateTimeImmutable( $value );
		}

		throw new InvalidArgumentException( 'Expected a date-compatible value.' );
	}

	/**
	 * @param array<string,mixed> $options Normalization options.
	 * @return array<int,mixed>
	 * @param mixed               $value
	 */
	private function normalizeList( $value, string $itemType, array $options ): array {
		if ( ! is_array( $value ) ) {
			throw new InvalidArgumentException( 'Expected a list value.' );
		}

		$normalized = array_values(
			array_map(
				fn ( $item ) => $this->normalize( $itemType, $item, $options ),
				$value
			)
		);

		if ( ! (bool) ( $options['ordered'] ?? true ) ) {
			$normalized = array_values( array_unique( $normalized, SORT_REGULAR ) );
			sort( $normalized );
		}

		return $normalized;
	}

	/**
	 * @param mixed $value
	 */
	private function normalizeHtml( $value ): string {
		if ( ! is_string( $value ) ) {
			throw new InvalidArgumentException( 'Expected an HTML string.' );
		}

		return trim( str_replace( array( "\r\n", "\r" ), "\n", $value ) );
	}

	/**
	 * @return array<string,mixed>
	 * @param mixed $value
	 */
	private function normalizeObject( $value ): array {
		if ( is_object( $value ) ) {
			$value = get_object_vars( $value );
		}

		if ( ! is_array( $value ) || ( array() !== $value && \ScheduledChanges\Support\PhpCompat::isList( $value ) ) ) {
			throw new InvalidArgumentException( 'Expected an associative object value.' );
		}

		$normalized = array();

		foreach ( $value as $key => $item ) {
			$normalized[ (string) $key ] = $this->normalizeMixed( $item );
		}

		ksort( $normalized );

		return $normalized;
	}

	/**
	 * @return array<int,mixed>
	 * @param mixed $value
	 */
	private function normalizeArray( $value ): array {
		if ( ! is_array( $value ) ) {
			throw new InvalidArgumentException( 'Expected an array value.' );
		}

		return array_values(
			array_map(
				fn ( $item ) => $this->normalizeMixed( $item ),
				$value
			)
		);
	}

	/**
	 * @param mixed $value
	 * @return mixed
	 */
	private function normalizeMixed( $value ) {
		if ( is_object( $value ) ) {
			return $this->normalizeObject( $value );
		}

		if ( ! is_array( $value ) ) {
			return $value;
		}

		return \ScheduledChanges\Support\PhpCompat::isList( $value )
			? $this->normalizeArray( $value )
			: $this->normalizeObject( $value );
	}
}
