<?php

namespace Aws\Api;

use Aws;
use InvalidArgumentException;

/**
 * Validates a schema against a hash of input.
 */
class Validator {
	private static $defaultConstraints = [
		'required' => true,
		'min'      => true,
		'max'      => false,
		'pattern'  => false
	];
	private $path = [];
	private $errors = [];
	private $constraints = [];

	/**
	 * @param array $constraints Associative array of constraints to enforce.
	 *                           Accepts the following keys: "required", "min",
	 *                           "max", and "pattern". If a key is not
	 *                           provided, the constraint will assume false.
	 */
	public function __construct( array $constraints = null ) {
		static $assumedFalseValues = [
			'required' => false,
			'min'      => false,
			'max'      => false,
			'pattern'  => false
		];
		$this->constraints = empty( $constraints )
			? self::$defaultConstraints
			: $constraints + $assumedFalseValues;
	}

	/**
	 * Validates the given input against the schema.
	 *
	 * @param string $name Operation name
	 * @param Shape $shape Shape to validate
	 * @param array $input Input to validate
	 *
	 * @throws InvalidArgumentException if the input is invalid.
	 */
	public function validate( $name, Shape $shape, array $input ) {
		$this->dispatch( $shape, $input );

		if ( $this->errors ) {
			$message      = sprintf(
				"Found %d error%s while validating the input provided for the "
				. "%s operation:\n%s",
				count( $this->errors ),
				count( $this->errors ) > 1 ? 's' : '',
				$name,
				implode( "\n", $this->errors )
			);
			$this->errors = [];

			throw new InvalidArgumentException( $message );
		}
	}

	private function dispatch( Shape $shape, $value ) {
		static $methods = [
			'structure' => 'check_structure',
			'list'      => 'check_list',
			'map'       => 'check_map',
			'blob'      => 'check_blob',
			'boolean'   => 'check_boolean',
			'integer'   => 'check_numeric',
			'float'     => 'check_numeric',
			'long'      => 'check_numeric',
			'string'    => 'check_string',
			'byte'      => 'check_string',
			'char'      => 'check_string'
		];

		$type = $shape->getType();
		if ( isset( $methods[ $type ] ) ) {
			$this->{$methods[ $type ]}( $shape, $value );
		}
	}

	private function check_structure( StructureShape $shape, $value ) {
		if ( ! $this->checkAssociativeArray( $value ) ) {
			return;
		}

		if ( $this->constraints['required'] && $shape['required'] ) {
			foreach ( $shape['required'] as $req ) {
				if ( ! isset( $value[ $req ] ) ) {
					$this->path[] = $req;
					$this->addError( 'is missing and is a required parameter' );
					array_pop( $this->path );
				}
			}
		}

		foreach ( $value as $name => $v ) {
			if ( $shape->hasMember( $name ) ) {
				$this->path[] = $name;
				$this->dispatch(
					$shape->getMember( $name ),
					isset( $value[ $name ] ) ? $value[ $name ] : null
				);
				array_pop( $this->path );
			}
		}
	}

	private function checkAssociativeArray( $value ) {
		$isAssociative = false;

		if ( is_array( $value ) ) {
			$expectedIndex = 0;
			$key           = key( $value );

			do {
				$isAssociative = $key !== $expectedIndex ++;
				next( $value );
				$key = key( $value );
			} while ( ! $isAssociative && null !== $key );
		}

		if ( ! $isAssociative ) {
			$this->addError( 'must be an associative array. Found '
			                 . Aws\describe_type( $value ) );

			return false;
		}

		return true;
	}

	private function addError( $message ) {
		$this->errors[] =
			implode( '', array_map( function ( $s ) {
				return "[{$s}]";
			}, $this->path ) )
			. ' '
			. $message;
	}

	private function check_list( ListShape $shape, $value ) {
		if ( ! is_array( $value ) ) {
			$this->addError( 'must be an array. Found '
			                 . Aws\describe_type( $value ) );

			return;
		}

		$this->validateRange( $shape, count( $value ), "list element count" );

		$items = $shape->getMember();
		foreach ( $value as $index => $v ) {
			$this->path[] = $index;
			$this->dispatch( $items, $v );
			array_pop( $this->path );
		}
	}

	private function validateRange( Shape $shape, $length, $descriptor ) {
		if ( $this->constraints['min'] ) {
			$min = $shape['min'];
			if ( $min && $length < $min ) {
				$this->addError( "expected $descriptor to be >= $min, but "
				                 . "found $descriptor of $length" );
			}
		}

		if ( $this->constraints['max'] ) {
			$max = $shape['max'];
			if ( $max && $length > $max ) {
				$this->addError( "expected $descriptor to be <= $max, but "
				                 . "found $descriptor of $length" );
			}
		}
	}

	private function check_map( MapShape $shape, $value ) {
		if ( ! $this->checkAssociativeArray( $value ) ) {
			return;
		}

		$values = $shape->getValue();
		foreach ( $value as $key => $v ) {
			$this->path[] = $key;
			$this->dispatch( $values, $v );
			array_pop( $this->path );
		}
	}

	private function check_blob( Shape $shape, $value ) {
		static $valid = [
			'string'   => true,
			'integer'  => true,
			'double'   => true,
			'resource' => true
		];

		$type = gettype( $value );
		if ( ! isset( $valid[ $type ] ) ) {
			if ( $type != 'object' || ! method_exists( $value, '__toString' ) ) {
				$this->addError( 'must be an fopen resource, a '
				                 . 'GuzzleHttp\Stream\StreamInterface object, or something '
				                 . 'that can be cast to a string. Found '
				                 . Aws\describe_type( $value ) );
			}
		}
	}

	private function check_numeric( Shape $shape, $value ) {
		if ( ! is_numeric( $value ) ) {
			$this->addError( 'must be numeric. Found '
			                 . Aws\describe_type( $value ) );

			return;
		}

		$this->validateRange( $shape, $value, "numeric value" );
	}

	private function check_boolean( Shape $shape, $value ) {
		if ( ! is_bool( $value ) ) {
			$this->addError( 'must be a boolean. Found '
			                 . Aws\describe_type( $value ) );
		}
	}

	private function check_string( Shape $shape, $value ) {
		if ( $shape['jsonvalue'] ) {
			if ( ! self::canJsonEncode( $value ) ) {
				$this->addError( 'must be a value encodable with \'json_encode\'.'
				                 . ' Found ' . Aws\describe_type( $value ) );
			}

			return;
		}

		if ( ! $this->checkCanString( $value ) ) {
			$this->addError( 'must be a string or an object that implements '
			                 . '__toString(). Found ' . Aws\describe_type( $value ) );

			return;
		}

		$this->validateRange( $shape, strlen( $value ), "string length" );

		if ( $this->constraints['pattern'] ) {
			$pattern = $shape['pattern'];
			if ( $pattern && ! preg_match( "/$pattern/", $value ) ) {
				$this->addError( "Pattern /$pattern/ failed to match '$value'" );
			}
		}
	}

	private function canJsonEncode( $data ) {
		return ! is_resource( $data );
	}

	private function checkCanString( $value ) {
		static $valid = [
			'string'  => true,
			'integer' => true,
			'double'  => true,
			'NULL'    => true,
		];

		$type = gettype( $value );

		return isset( $valid[ $type ] ) ||
		       ( $type == 'object' && method_exists( $value, '__toString' ) );
	}
}
