<?php

namespace Aws\DynamoDb;

use InvalidArgumentException;
use Psr\Http\Message\StreamInterface;
use stdClass;
use Traversable;
use UnexpectedValueException;

/**
 * Marshals and unmarshals JSON documents and PHP arrays into DynamoDB items.
 */
class Marshaler {
	/** @var array Default options to merge into provided options. */
	private static $defaultOptions = [
		'ignore_invalid'  => false,
		'nullify_invalid' => false,
		'wrap_numbers'    => false,
	];

	/** @var array Marshaler options. */
	private $options;

	/**
	 * Instantiates a DynamoDB Marshaler.
	 *
	 * The following options are valid.
	 *
	 * - ignore_invalid: (bool) Set to `true` if invalid values should be
	 *   ignored (i.e., not included) during marshaling.
	 * - nullify_invalid: (bool) Set to `true` if invalid values should be set
	 *   to null.
	 * - wrap_numbers: (bool) Set to `true` to wrap numbers with `NumberValue`
	 *   objects during unmarshaling to preserve the precision.
	 *
	 * @param array $options Marshaler options
	 */
	public function __construct( array $options = [] ) {
		$this->options = $options + self::$defaultOptions;
	}

	/**
	 * Creates a special object to represent a DynamoDB number (N) value.
	 *
	 * This helps maintain the precision of large integer/float in PHP.
	 *
	 * @param string|int|float $value A number value.
	 *
	 * @return NumberValue
	 */
	public function number( $value ) {
		return new NumberValue( $value );
	}

	/**
	 * Creates a special object to represent a DynamoDB set (SS/NS/BS) value.
	 *
	 * This helps disambiguate set values from list (L) values.
	 *
	 * @param array $values The values of the set.
	 *
	 * @return SetValue
	 *
	 */
	public function set( array $values ) {
		return new SetValue( $values );
	}

	/**
	 * Marshal a JSON document from a string to a DynamoDB item.
	 *
	 * The result is an array formatted in the proper parameter structure
	 * required by the DynamoDB API for items.
	 *
	 * @param string $json A valid JSON document.
	 *
	 * @return array Item formatted for DynamoDB.
	 * @throws InvalidArgumentException if the JSON is invalid.
	 */
	public function marshalJson( $json ) {
		$data = json_decode( $json );
		if ( ! ( $data instanceof stdClass ) ) {
			throw new InvalidArgumentException(
				'The JSON document must be valid and be an object at its root.'
			);
		}

		return current( $this->marshalValue( $data ) );
	}

	/**
	 * Marshal a native PHP value into a DynamoDB attribute value.
	 *
	 * The result is an associative array that is formatted in the proper
	 * `[TYPE => VALUE]` parameter structure required by the DynamoDB API.
	 *
	 * @param mixed $value A scalar, array, or `stdClass` value.
	 *
	 * @return array Attribute formatted for DynamoDB.
	 * @throws UnexpectedValueException if the value cannot be marshaled.
	 */
	public function marshalValue( $value ) {
		$type = gettype( $value );

		// Handle string values.
		if ( $type === 'string' ) {
			if ( $value === '' ) {
				return $this->handleInvalid( 'empty strings are invalid' );
			}

			return [ 'S' => $value ];
		}

		// Handle number values.
		if ( $type === 'integer'
		     || $type === 'double'
		     || $value instanceof NumberValue
		) {
			return [ 'N' => (string) $value ];
		}

		// Handle boolean values.
		if ( $type === 'boolean' ) {
			return [ 'BOOL' => $value ];
		}

		// Handle null values.
		if ( $type === 'NULL' ) {
			return [ 'NULL' => true ];
		}

		// Handle set values.
		if ( $value instanceof SetValue ) {
			if ( count( $value ) === 0 ) {
				return $this->handleInvalid( 'empty sets are invalid' );
			}
			$previousType = null;
			$data         = [];
			foreach ( $value as $v ) {
				$marshaled = $this->marshalValue( $v );
				$setType   = key( $marshaled );
				if ( ! $previousType ) {
					$previousType = $setType;
				} elseif ( $setType !== $previousType ) {
					return $this->handleInvalid( 'sets must be uniform in type' );
				}
				$data[] = current( $marshaled );
			}

			return [ $previousType . 'S' => array_values( array_unique( $data ) ) ];
		}

		// Handle list and map values.
		$dbType = 'L';
		if ( $value instanceof stdClass ) {
			$type   = 'array';
			$dbType = 'M';
		}
		if ( $type === 'array' || $value instanceof Traversable ) {
			$data  = [];
			$index = 0;
			foreach ( $value as $k => $v ) {
				if ( $v = $this->marshalValue( $v ) ) {
					$data[ $k ] = $v;
					if ( $dbType === 'L' && ( ! is_int( $k ) || $k != $index ++ ) ) {
						$dbType = 'M';
					}
				}
			}

			return [ $dbType => $data ];
		}

		// Handle binary values.
		if ( is_resource( $value ) || $value instanceof StreamInterface ) {
			$value = $this->binary( $value );
		}
		if ( $value instanceof BinaryValue ) {
			return [ 'B' => (string) $value ];
		}

		// Handle invalid values.
		return $this->handleInvalid( 'encountered unexpected value' );
	}

	/**
	 * Handle invalid value based on marshaler configuration.
	 *
	 * @param string $message Error message
	 *
	 * @return array|null
	 */
	private function handleInvalid( $message ) {
		if ( $this->options['ignore_invalid'] ) {
			return null;
		}

		if ( $this->options['nullify_invalid'] ) {
			return [ 'NULL' => true ];
		}

		throw new UnexpectedValueException( "Marshaling error: {$message}." );
	}

	/**
	 * Creates a special object to represent a DynamoDB binary (B) value.
	 *
	 * This helps disambiguate binary values from string (S) values.
	 *
	 * @param mixed $value A binary value compatible with Guzzle streams.
	 *
	 * @return BinaryValue
	 * @see GuzzleHttp\Stream\Stream::factory
	 */
	public function binary( $value ) {
		return new BinaryValue( $value );
	}

	/**
	 * Marshal a native PHP array of data to a DynamoDB item.
	 *
	 * The result is an array formatted in the proper parameter structure
	 * required by the DynamoDB API for items.
	 *
	 * @param array|stdClass $item An associative array of data.
	 *
	 * @return array Item formatted for DynamoDB.
	 */
	public function marshalItem( $item ) {
		return current( $this->marshalValue( $item ) );
	}

	/**
	 * Unmarshal a document (item) from a DynamoDB operation result into a JSON
	 * document string.
	 *
	 * @param array $data Item/document from a DynamoDB result.
	 * @param int $jsonEncodeFlags Flags to use with `json_encode()`.
	 *
	 * @return string
	 */
	public function unmarshalJson( array $data, $jsonEncodeFlags = 0 ) {
		return json_encode(
			$this->unmarshalValue( [ 'M' => $data ], true ),
			$jsonEncodeFlags
		);
	}

	/**
	 * Unmarshal a value from a DynamoDB operation result into a native PHP
	 * value. Will return a scalar, array, or (if you set $mapAsObject to true)
	 * stdClass value.
	 *
	 * @param array $value Value from a DynamoDB result.
	 * @param bool $mapAsObject Whether maps should be represented as stdClass.
	 *
	 * @return mixed
	 * @throws UnexpectedValueException
	 */
	public function unmarshalValue( array $value, $mapAsObject = false ) {
		$type  = key( $value );
		$value = $value[ $type ];
		switch ( $type ) {
			case 'S':
			case 'BOOL':
				return $value;
			case 'NULL':
				return null;
			case 'N':
				if ( $this->options['wrap_numbers'] ) {
					return new NumberValue( $value );
				}

				// Use type coercion to unmarshal numbers to int/float.
				return $value + 0;
			case 'M':
				if ( $mapAsObject ) {
					$data = new stdClass;
					foreach ( $value as $k => $v ) {
						$data->$k = $this->unmarshalValue( $v, $mapAsObject );
					}

					return $data;
				}
			// NOBREAK: Unmarshal M the same way as L, for arrays.
			case 'L':
				foreach ( $value as $k => $v ) {
					$value[ $k ] = $this->unmarshalValue( $v, $mapAsObject );
				}

				return $value;
			case 'B':
				return new BinaryValue( $value );
			case 'SS':
			case 'NS':
			case 'BS':
				foreach ( $value as $k => $v ) {
					$value[ $k ] = $this->unmarshalValue( [ $type[0] => $v ] );
				}

				return new SetValue( $value );
		}

		throw new UnexpectedValueException( "Unexpected type: {$type}." );
	}

	/**
	 * Unmarshal an item from a DynamoDB operation result into a native PHP
	 * array. If you set $mapAsObject to true, then a stdClass value will be
	 * returned instead.
	 *
	 * @param array $data Item from a DynamoDB result.
	 * @param bool $mapAsObject Whether maps should be represented as stdClass.
	 *
	 * @return array|stdClass
	 */
	public function unmarshalItem( array $data, $mapAsObject = false ) {
		return $this->unmarshalValue( [ 'M' => $data ], $mapAsObject );
	}
}
