<?php
/**
 * Validator class
 * @author NHN (developers@xpressengine.com)
 * @package /classes/validator
 * @version 0.1
 */
class Validator
{
	/**
	 * cache directory
	 * @var string
	 */
	var $_cache_dir = '';
	/**
	 * last error
	 * @var array
	 */
	var $_last_error;
	/**
	 * xml ruleset object
	 * @var Xml_Node_ object
	 */
	var $_xml_ruleset = null;
	/**
	 * rule list
	 * @var array
	 */
	var $_rules;
	/**
	 * filter list
	 * @var array
	 */
	var $_filters;
	/**
	 * Can usable status for multibyte string function
	 * @var boolean
	 */
	var $_has_mb_func;
	/**
	 * validator version
	 * @var string
	 */
	var $_version = '1.0';
	/**
	 * ruleset xml file path
	 * @var string
	 */
	var $_xml_path = '';

	/**
	 * @constructor
	 * @param string $xml_path
	 * @return void
	 */
	function Validator($xml_path='') {
		$this->__construct($xml_path);
	}

	/**
	 * @constructor
	 * @param string $xml_path
	 * @return void
	 */
	function __construct($xml_path='') {
		$this->_rules   = array();
		$this->_filters = array();
		$this->_xml_ruleset = null;

		if($xml_path) $this->load($xml_path);

		// predefined rules
		$this->addRule(array(
			'email'        => '/^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$/',
			'userid'       => '/^[a-z]+[\w-]*[a-z0-9_]+$/i',
			'url'          => '/^(https?|ftp|mms):\/\/[0-9a-z-]+(\.[_0-9a-z-]+)+(:\d+)?/',
			'alpha'        => '/^[a-z]*$/i',
			'alpha_number' => '/^[a-z][a-z0-9_]*$/i',
			'number'       => '/^(?:[1-9]\\d*|0)$/'
		));

		$this->_has_mb_func = is_callable('mb_strlen');
		$this->setCacheDir('./files/cache');
	}

	/**
	 * @destructor
	 * @return void
	 */
	function __destruct() {
		$this->_rules   = null;
		$this->_filters = null;
	}

	/**
	 * Load a xml file
	 * @param string $xml_path A file name to be loaded
	 * @return boolean
	 */
	function load($xml_path) {
		$this->_xml_ruleset = null;

		$xml_path = realpath($xml_path);
		if(!is_readable($xml_path)) return false;

		$parser = new XmlParser();
		$xml = $parser->loadXmlFile($xml_path);
		if(!isset($xml->ruleset) || !isset($xml->ruleset->fields) || !isset($xml->ruleset->fields->field)) return false;

		// custom rules
		if(isset($xml->ruleset->customrules) && isset($xml->ruleset->customrules->rule)) {
			$customrules = $xml->ruleset->customrules->rule;
			if(!is_array($customrules)) $customrules = array($customrules);

			$rules = array();
			foreach($customrules as $rule) {
				if(!isset($rule->attrs) || !isset($rule->attrs->name)) continue;

				$rule = (array)$rule->attrs;
				$name = $rule['name'];
				unset($rule['name']);

				$rules[$name] = $rule;
			}
			if(count($rules)) $this->addRule($rules);
		}

		// filters
		$fields = $xml->ruleset->fields->field;
		if(!is_array($fields)) $fields = array($fields);

		$filters = array();
		foreach($fields as $field) {
			$name   = '';
			$filter = array();

			if(!isset($field->attrs) || !isset($field->attrs->name)) continue;
			$filter = (array)$field->attrs;

			$name = $filter['name'];
			unset($filter['name']);

			// conditional statement
			if(isset($field->if)) {
				$if = $field->if;
				if(!is_array($if)) $if = array($if);
				foreach($if as $idx=>$cond) {
					$if[$idx] = (array)$cond->attrs;
				}
				$filter['if'] = $if;
			}

			$filters[$name] = $filter;
		}

		$this->_xml_ruleset = $xml->ruleset;
		$this->_filters  = $filters;
		$this->_xml_path = $xml_path;

		return true;
	}

	/**
	 * Set root cache directory
	 * @param string $cache_dir Root cache directory
	 * @return void
	 */
	function setCacheDir($cache_dir){
		if(is_dir($cache_dir)) {
			$this->_cache_dir = preg_replace('@/$@', '', $cache_dir);
		}
	}

	/**
	 * Validate the fields. If the fields aren't passed, validation will be execute on the Context variables.
	 * @param array $fields Target fields. The keys of the array represents field's name, its values represents field's value.
	 * @return boolean TRUE if it is valid, FALSE otherwise.
	 */
	function validate($fields_=null) {
		if(is_array($fields_)) {
			$fields = $fields_;
		} else {
			$args   = array_keys($this->_filters);
			$fields = (array)Context::getRequestVars();
		}

		if(!is_array($fields)) return true;

		$filter_default = array(
			'required'  => 'false',
			'default'   => '',
			'modifiers' => array(),
			'length'    => 0,
			'equalto'   => 0,
			'rule'      => 0,
			'if'        => array()
		);

		$fields = array_map(array($this, 'arrayTrim'), $fields);
		$field_names = array_keys($fields);

		$filters = $this->_filters;

		// get field names matching patterns
		foreach($this->_filters as $key=>$filter) {
			$names = array();
			if($key{0} == '^') {
				$names = preg_grep('/^'.preg_quote(substr($key,1)).'/', $field_names);
			}elseif(substr($key,-2) == '[]'){
				$filters[substr($key,0,-2)] = $filter;
				unset($filters[$key]);
			}

			if(!count($names)) continue;

			foreach($names as $name) {
				$filters[$name] = $filter;
			}
			
			unset($filters[$key]);
		}

		foreach($filters as $key=>$filter) {
			$fname  = preg_replace('/\[\]$/', '', $key);
			$filter = array_merge($filter_default, $filter);

			if(preg_match("/(^[a-z_]*)[\[](?:\'|\")?([a-z_]*)(?:\'|\")?[\]]$/i", $key, $matches))
			{
				$exists = array_key_exists($matches[1], $fields);
				$value  = $exists ? $fields[$matches[1]][$matches[2]] : null;
			}
			else
			{
				$exists = array_key_exists($key, $fields);
				$value  = $exists ? $fields[$fname] : null;
			}

			if(is_array($value)) $value = implode('', $value);

			// conditional statement
			foreach($filter['if'] as $cond) {
				if(!isset($cond['test']) || !isset($cond['attr'])) continue;

				$func_body = preg_replace('/\\$(\w+)/', '$c[\'$1\']', $cond['test']);
				$func = create_function('$c', "return !!({$func_body});");

				if($func($fields)) $filter[$cond['attr']] = $cond['value'];
			}

			// attr : default
			if(!$value && strlen($default=trim($filter['default']))) {
				$value = $default;
				if(is_null($fields_)) Context::set($fname, $value);
				else $fields_[$fname] = $value;
			}
			$value_len = strlen($value);

			// attr : modifier
			if(is_string($modifiers=$filter['modifiers'])) $modifiers = explode(',', trim($modifiers));

			// attr : required
			if($filter['required'] === 'true' && !$value_len) return $this->error($key, 'isnull');

			// if the field wasn't passed, ignore this value
			if(!$exists && !$value_len) continue;

			// attr : length
			if($length=$filter['length']){
				list($min, $max) = explode(':', trim($length));
				$is_min_b = (substr($min, -1) === 'b');
				$is_max_b = (substr($max, -1) === 'b');
				list($min, $max) = array((int)$min, (int)$max);

				$strbytes = strlen($value);
				if(!$is_min_b || !$is_max_b) {
					$strlength = $this->_has_mb_func?mb_strlen($value,'utf-8'):$this->mbStrLen($value);
				}

				if(($min && $min > ($is_min_b?$strbytes:$strlength)) || ($max && $max < ($is_max_b?$strbytes:$strlength))) return $this->error($key, 'outofrange');
			}

			// equalto
			if($equalto=$filter['equalto']){
				if(!array_key_exists($equalto, $fields) || trim($fields[$equalto]) !== $value) return $this->error($key, 'equalto');
			}

			// rules
			if($rules=$filter['rule']){
				$rules = explode(',', $rules);
				foreach($rules as $rule) {
					$result = $this->applyRule($rule, $value);
					// apply the 'not' modifier
					if(in_array('not', $modifiers)) $result = !$result;
					if(!$result) return $this->error($key, 'invalid_'.$rule);
				}
			}
		}

		return true;
	}

	/**
	 * apply trim recursive
	 * @param string|array $array
	 * @return string|array
	 */
	function arrayTrim($array)
	{
		if (!is_array($array)) return trim($array);

		foreach($array as $key => $value)
		{
			$array[$key] = $this->arrayTrim($value);
		}

		return $array;
	}

	/**
	 * Log an error
	 * @param $msg error message
	 * @return boolean always false
	 */
	function error($field, $msg){
		$lang_filter = Context::getLang('filter');
		$msg = isset($lang_filter->{$msg})?$lang_filter->{$msg}:$lang_filter->invalid;
		$msg = sprintf($msg, Context::getLang($field));

		$this->_last_error = array('field'=>$field, 'msg'=>$msg);

		return false;
	}

	/**
	 * Returns the last error infomation including a field name and an error message.
	 * @return array The last error infomation
	 */
	function getLastError(){
		return $this->_last_error;
	}

	/**
	 * Add a new rule
	 * @param string $name rule name
	 * @param mixed $rule
	 * @return void
	 */
	function addRule($name, $rule=''){
		if(is_array($name)) $args = $name;
		else $args = array($name=>$rule);

		foreach($args as $name=>$rule){
			if(!$rule) continue;
			if(is_string($rule)) $rule = array('type'=>'regex', 'test'=>$rule);

			if($rule['type'] == 'enum') {
				$delim = isset($rule['delim'])?$rule['delim']:',';
				$rule['test'] = explode($delim, $rule['test']);
			}

			$this->_rules[$name] = $rule;
		}
	}

	/**
	 * Remove a rule
	 * @param string $name rule name
	 * @return void
	 */
	function removeRule($name){
		unset($this->_rules[$name]);
	}

	/**
	 * add filter to filter list
	 * @param string $name rule name
	 * @param string $filter filter
	 * @return void
	 */
	function addFilter($name, $filter='') {
		if(is_array($name)) $args = $name;
		else $args = array($name=>$filter);

		foreach($args as $name=>$filter) {
			if(!$filter) continue;

			if(isset($filter['if'])) {
				if(is_array($filter['if']) && count($filter['if'])) {
					$key = key($filter['if']);
					if(!is_int($key)) $filter['if'] = array($filter['if']);
				} else {
					unset($filter['if']);
				}
			}

			$this->_filters[$name] = $filter;
		}
	}

	/**
	 * remove filter from filter list
	 * @param string $name rule name
	 * @return void
	 */
	function removeFilter($name) {
		unset($this->_filters[$name]);
	}

	/**
	 * Find whether the field is valid with the rule
	 * @param string $name rule name
	 * @param string $value a value to be validated
	 * @return boolean TRUE if the field is valid, FALSE otherwise.
	 */
	function applyRule($name, $value){
		$rule = $this->_rules[$name];

		if (is_array($value) && isset($value['tmp_name']))
		{
			$value = $value['name'];
		}

		switch($rule['type']) {
			case 'regex':
				return (preg_match($rule['test'], $value) > 0);
			case 'enum':
				return in_array($value, $rule['test']);
			case 'expr':
				if(!$rule['func_test']) {
					$rule['func_test'] = create_function('$a', 'return ('.preg_replace('/\$\$/', '$a', html_entity_decode($rule['test'])).');');
				}
				return $rule['func_test']($value);
		}

		return true;
	}

	/**
	 * if not supported 'mb_strlen' function, this method can use.
	 * @param string $str
	 * @return int
	 */
	function mbStrLen($str){
		$arr = count_chars($str);
		for($i=0x80; $i < 0xc0; $i++) {
			unset($arr[$i]);
		}
		return array_sum($arr);
	}

	/**
	 * Returns compiled javascript file path. The path begins from XE root directory.
	 * @return string Compiled JavaScript file path
	 */
	function getJsPath(){
		if(!$this->_cache_dir) return false;

		$dir = $this->_cache_dir.'/ruleset';
		if(!is_dir($dir) && !mkdir($dir)) return false;
		if(!$this->_xml_path) return false;

		// current language
		$lang_type = class_exists('Context')?Context::getLangType():'en';

		// check the file
		$filepath = $dir.'/'.md5($this->_version.' '.$this->_xml_path).".{$lang_type}.js";
		if(is_readable($filepath) && filemtime($filepath) > filemtime($this->_xml_path)) return $filepath;

		$content = $this->_compile2js();
		if($content === false) return false;

		if(is_callable('file_put_contents')) {
			@file_put_contents($filepath, $content);
		} else {
			$fp = @fopen($filepath, 'w');
			if(is_resource($fp)) {
				fwrite($fp, $content);
				fclose($fp);
			}
		}

		return $filepath;
	}

	/**
	 * Compile a ruleset to a javascript file
	 * @return string
	 */
	function _compile2js() {
		global $lang;

		$ruleset = basename($this->_xml_path,'.xml');
		$content = array();

		if(preg_match('@(^|/)files/ruleset/\w+\.xml$@i', $this->_xml_path)) $ruleset = '@'.$ruleset;

		list($ruleset) = explode('.', $ruleset);

		// current language
		$lang_type = class_exists('Context')?Context::getLangType():'en';

		// custom rulesets
		$addrules = array();
		foreach($this->_rules as $name=>$rule) {
			if(strpos('email,userid,url,alpha,alpha_number,number,', $name.',') !== false) continue;
			switch($rule['type']) {
				case 'regex':
					$content[] = "v.cast('ADD_RULE', ['{$name}', {$rule['test']}]);";
					break;
				case 'enum':
					$enums = '"'.implode('","', $rule['test']).'"';
					$content[] = "v.cast('ADD_RULE', ['{$name}', function($$){ return ($.inArray($$,[{$enums}]) > -1); }]);";
					break;
				case 'expr':
					$content[] = "v.cast('ADD_RULE', ['{$name}', function($$){ return ({$rule['test']}); }]);";
					break;
			}
		}
		$addrules = implode('', $addrules);

		// filters
		$content  = array();
		$messages = array();
		foreach($this->_filters as $name=>$filter) {
			$field = array();

			// form filed name
			if(isset($lang->{$name})) {
				$field_lang = addslashes($lang->{$name});
				$messages[] = "v.cast('ADD_MESSAGE',['{$name}','{$field_lang}']);";
			}

			if($filter['required'] == 'true') $field[] = 'required:true';
			if($filter['rule'])     $field[] = "rule:'{$filter['rule']}'";
			if($filter['default'])  $field[] = "default:'{$filter['default']}'";
			if($filter['modifier']) $field[] = "modifier:'{$filter['modifier']}'";
			if($filter['length']) {
				list($min, $max) = explode(':', $filter['length']);
				if($min) $field[] = "minlength:'{$min}'";
				if($max) $field[] = "maxlength:'{$max}'";
			}
			if($filter['if']) {
				$ifs = array();
				if(!isset($filter['if'][0])) $filter['if'] = array($filter['if']);
				foreach($filter['if'] as $if) {
					$ifs[] = "{test:'".addslashes($if['test'])."', attr:'{$if['attr']}', value:'".addslashes($if['value'])."'}";
				}
				$field[] = "'if':[".implode(',', $ifs)."]";
			}
			if(count($field)) {
				$field = '{'.implode(',', $field).'}';
				$content[] = "'{$name}':{$field}";
			}
		}

		if(!$content) return '/* Error : empty ruleset  */';

		// error messages
		foreach($lang->filter as $key=>$text) {
			if($text) {
				$text = preg_replace('@\r?\n@', '\\n', addslashes($text));
				$messages[] = "v.cast('ADD_MESSAGE',['{$key}','{$text}']);";
			}
		}

		$content  = implode(',', $content);
		$messages = implode("\n", $messages);

		return "(function($,v){\nv=xe.getApp('validator')[0];if(!v)return;\n{$addrules}\nv.cast('ADD_FILTER',['{$ruleset}', {{$content}}]);\n{$messages}\n})(jQuery);";
	}
}

/* End of file Validator.class.php */
/* Location: ./classes/validator/Validator.class.php */
