<?php 

class validator {
	
	const ANY='any';
	const REQUIRED='required';
	const ALPHANUMERIC='alphanumeric';
	const ALPHANUMERIC_PLUS_UNDERSCORE='alphanumericplusunderscore';
	const INTEGER='integer';
	const FLOAT='float';
	const BOOLEAN='boolean';
	const EMAIL='email';
	const IP4ADDRESS='ip4';
	const USERNAME='username';
	const DATETIME='datetime';
	const DATE='date';
	const COLOR='color';
	const JSON="JSON";
	
	const GROUPVISIBILITY='groupvisibility';
	const GROUPMEMBERSHIPVISIBILITY='membershipvisibility';
	const GROUPJOINING='joining';
	const PERMISSIONTYPE='permissiontype';

    const GEOFENCE='geofence';

	//TODO Application-specific, extend this base class
	const DNASEQUENCE='dnaSequence';
	const PROTEINSEQUENCE='proteinSequence';
	const DROPMAPPING='dropmapping';
	const PDBCODE='pdbcode';
	const PDBCODE_COMMASEPARATED='pdbcodemultiple';

	const V7_UUID_DASHED='v7UuidDashed';
	const V7_UUID_UNDASHED='v7UuidUndashed';
	const V7_UUID_DASHED_OR_UNDASHED='v7UuidDashedOrUndashed';

    /**
     * @throws ServerException
     * @throws BadRequestException
     */
    public static function validateAll(array $keyValuePairs, array $validations, bool $enforceRequired): bool {
        foreach ($keyValuePairs as $key=>$value) {
            if(isset($validations[$key])){
                validator::validate($key, $value, $validations[$key]);
            }
        }
        if($enforceRequired){
            foreach ($validations as $field=>$fieldValidations){
                if(!is_array($fieldValidations)){ $fieldValidations=[$fieldValidations]; }
                if(in_array(validator::REQUIRED, $fieldValidations)){
                    if(!in_array($field, array_keys($keyValuePairs))){
                        throw new BadRequestException("Field $field is required.");
                    }
                }
            }
        }
        return true;
    }

    /**
     * ValidateS $fieldValue and returns true if valid or throws a BadRequestException if invalid.
     * @param string $fieldName The name of the field being validated. Used only in the failure case.
     * @param string|null $fieldValue The value to validate
     * @param string|array $validationNames The name of the validation to perform. Use constants like validator::REQUIRED to take advantage of your IDE's autocomplete.
     * @return boolean true if $fieldValue is valid.
     * @throws BadRequestException if $fieldValue is not valid.
     * @throws ServerException
     */
	public static function validate(string $fieldName, ?string $fieldValue, array|string $validationNames): bool {
		if(!is_array($validationNames)){
			$validationNames=array($validationNames);
		}
		foreach($validationNames as $vn){
			if(!validator::isValid($fieldValue, $vn)){
				$message='"'.$fieldValue.'" is not valid for field "'.$fieldName.'"';
				$validation=validator::$validations[$vn];
				if($validation && isset($validation['message'])){
					$message='Field "'.$fieldName.'"'.$validation['message'];
				}
				throw new BadRequestException($message);
			}
		}
		return true;
	}
	
	/**
	 * Determines whether $fieldValue is valid, in a way specified by $validationName. Returns true if valid, false otherwise.
	 * @param string|null $fieldValue The value to validate
	 * @param array|string $validationNames The name(s) of the validation(s) to perform. Use constants like validator::REQUIRED to take advantage of your IDE's autocomplete.
	 * @return boolean true if $fieldname validates, false otherwise
	 *@throws ServerException if $validationName is not a known validation.
	 */
	public static function isValid(?string $fieldValue, array|string $validationNames): bool {
		if(!is_array($validationNames)){
			$validationNames=array($validationNames);
		}
		$isValid=true;
		/*
		if(!in_array(validator::REQUIRED, $validationNames) && empty($fieldValue)){
			return true;
		}
		if($fieldValue==database::$nullValue){ return true; }
		*/
		if(!in_array(validator::REQUIRED, $validationNames) && (empty($fieldValue)||database::$nullValue==$fieldValue)){
		    return true;
		}
		if(is_null($fieldValue)){ $fieldValue=""; }
		foreach($validationNames as $validationName){
			if(!isset(validator::$validations[$validationName])){
				throw new ServerException('Server tried to validate string "'.$fieldValue.'" with non-existent validation type "'.$validationName.'"');
			}
			$validation=validator::$validations[$validationName];
			if(isset($validation['pattern']) && !preg_match('/^'.$validation['pattern'].'$/', $fieldValue)) { $isValid=false; break; }
			if(isset($validation['helper']) && !forward_static_call('validator::'.$validation['helper'], $fieldValue)) { $isValid=false; break; }
		}
		return $isValid;
	}
	
	public static function getValidationPatterns(): array {
		return validator::$validations;
	}
	
	/**
	 * 
	 * NOTE: If adding helper functions, these need to be mirrored in the client Javascript validator.
	 */
	private static array $validations=array(
			validator::ANY=>array(
				'message'=>'' //No validation, so shouldn't ever need a message
			),
			validator::REQUIRED=>array(
				'pattern'=>'.+',
				'message'=>' is required.'
			),
    	    validator::ALPHANUMERIC=>array(
    	        'pattern'=>'[A-Za-z0-9]*',
    	        'message'=>' must be alphanumeric.'
    	    ),
    	    validator::ALPHANUMERIC_PLUS_UNDERSCORE=>array(
    	        'pattern'=>'[A-Za-z0-9_]*',
    	        'message'=>' must be alphanumeric (underscores allowed).'
    	    ),
    	    validator::INTEGER=>array(
				'pattern'=>'-?[0-9]*',
				'message'=>' must be a whole number.'
			),
			validator::FLOAT=>array(
				'pattern'=>'-?(\d*[,\.])?\d+',
				'message'=>' must be a number.'
			),
			
			validator::COLOR=>array(
					'pattern'=>'[0-9A-Fa-f]{6}',
					'message'=>' must be an HTML hex colour code, without the preceding #.'
			),
				
			validator::DATETIME=>array(
				'pattern'=>'\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d',
				'message'=>' must be a date/time in "YYYY-MM-DD hh:mm:ss" format.'
			),
			validator::DATE=>array(
				'pattern'=>'\d\d\d\d-\d\d-\d\d',
				'message'=>' must be a date in "YYYY-MM-DD" format.'
			),
			
			validator::BOOLEAN=>array(
				'pattern'=>'[01]',
				'message'=>' must be either 1 (true) or 0 (false).'
			),
			validator::EMAIL=>array(
				'helper'=>'isValidEmailAddress',
				'message'=>' must be a valid email address.'
			),
			validator::IP4ADDRESS=>array(
                'helper'=>'isValidIPv4Address',
                'message'=>' must be a valid IPv4 address.'
            ),
			validator::USERNAME=>array(
				'pattern'=>'[A-Za-z][\.A-Za-z0-9@_-]+',
				'message'=>' must begin with a letter and contain only letters, numbers, period (.), underscore, at (@), and dash.'
			),
			
			validator::GROUPVISIBILITY=>array(
				'pattern'=>'visible|hidden|membersonly',
				'message'=>' must be one of visible, hidden or membersonly.'
			),
			validator::GROUPMEMBERSHIPVISIBILITY=>array(
				'pattern'=>'visible|hidden|membersonly',
				'message'=>' must be one of visible, hidden or membersonly.'
			),
			validator::GROUPJOINING=>array(
				'pattern'=>'open|request|closed|auto',
				'message'=>' must be open, request, closed or auto.'
			),
			validator::PERMISSIONTYPE=>array(
				'pattern'=>'create|read|update|delete',
				'message'=>' must be create, read, update or delete.'
			),

            validator::GEOFENCE=>array(
                'helper'=>'isValidGeofence',
                'message'=>' must be lat,long,radius or at least 3 lat,long pairs, all numbers in a comma-separated list).'
            ),

			validator::JSON=>array(
				'helper'=>'isValidJSON',
				'message'=>' must be a valid JSON object.'
			),

        /* TODO Application-specific, should move */
			
			validator::DNASEQUENCE=>array(
				'helper'=>'isValidDnaSequence',
				'message'=>' must be a valid DNA sequence. Only A, C, G and T are valid, and the length must be divisible by 3.'
			),
			validator::PROTEINSEQUENCE=>array(
				'pattern'=>'^[\*\sACDEFGHIKLMNPQRSTVWY]*$',
				'message'=>' must be a valid protein sequence.'
			),
            validator::DROPMAPPING=>array(
                'pattern'=>'[0-9ERX,]+',
                'message'=>' must be a valid drop mapping.'
            ),

            //https://proteopedia.org/wiki/index.php/PDB_identification_code#Future_Plans_for_Expanded_PDB_Codes
            validator::PDBCODE=>array(
                'pattern'=>'(pdb_\d\d\d\d)?\d[a-zA-Z0-9]{3}',
                'message'=>' must be a valid PDB code.'
            ),
            validator::PDBCODE_COMMASEPARATED=>array(
                'pattern'=>'(?:pdb_\d\d\d\d)?\d[a-zA-Z0-9]{3}(?:, ?(?:pdb_\d\d\d\d)?\d[a-zA-Z0-9]{3})*',
                'message'=>' must be a comma-separated list of PDB codes.'
            ),
			validator::V7_UUID_DASHED=>array(
				'pattern'=>'[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-?[1-5][a-fA-F0-9]{3}-[89abAB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}',
				'message'=>' must be a valid v7 UUID with dashes.'
			),
			validator::V7_UUID_UNDASHED=>array(
				'pattern'=>'[a-fA-F0-9]{8}[a-fA-F0-9]{4}[1-5][a-fA-F0-9]{3}[89abAB][a-fA-F0-9]{3}[a-fA-F0-9]{12}',
				'message'=>' must be a valid v7 UUID without dashes.'
			),
			validator::V7_UUID_DASHED_OR_UNDASHED=>array(
				'pattern'=>'[a-fA-F0-9]{8}-?[a-fA-F0-9]{4}-?[1-5][a-fA-F0-9]{3}-?[89abAB][a-fA-F0-9]{3}-?[a-fA-F0-9]{12}',
				'message'=>' must be a valid v7 UUID, with or without dashes.'
			)
	);

	private static function isValidJSON(string $json): bool {
		if(!@json_decode($json)) { return false; }
		return true;
	}

	/**
	 * @param string $seq
	 * @return bool
	 */
	private static function isValidDnaSequence(string $seq): bool {
		$seq=strtoupper(str_replace(' ','',$seq));
		if(!preg_match('/^[ACGT]*$/', $seq)){ return false; }
		if(strlen($seq)%3 != 0){ return false; }
		return true;
	}

	/**
	 * @param string $email
	 * @return bool
	 */
    private static function isValidEmailAddress(string $email): bool {
        if(filter_var($email, FILTER_VALIDATE_EMAIL)===false){
            return false;
        }
        return true;
    }

	/**
	 * @param string $ip
	 * @return bool
	 */
    private static function isValidIPv4Address(string $ip): bool {
        if(filter_var($ip, FILTER_VALIDATE_IP)===false){
            return false;
        }
        return true;
    }

	/**
	 * Checks whether a string is a valid geofence. A valid geofence is a comma-separated list of numbers, either
	 * lat,long,radius(m) or at least three lat,long pairs. Note that this validation DOES NOT check whether the
	 * sides of a closed-polygon geofence cross; caller must avoid this by sensible point selection.
	 * @param string $geofence
	 * @return false
	 */
    private static function isValidGeofence(string $geofence):bool {
        $parts=explode(',', $geofence);
        $numParts=count($parts);
        foreach($parts as $part){
            if(!is_numeric($part)){
                return false;
            }
        }
        if(3===$numParts){
            //This is a lat,long,radius circular geofence. Lat/long must be within range and radius must be positive.
            if($parts[0]<-90 || $parts[0]>90){ return false; }
            if($parts[1]<-180 || $parts[1]>180){ return false; }
            if(0>=$parts[2]){ return false; }
        } else {
            //This is a series of points forming a closed polygon. There must be at least three lat,long pairs,
            //so $numParts must be at least 6 and must be divisible by 2.
            if ($numParts < 6 || 0 !== $numParts % 2) {
                return false;
            }
            for($i=0;$i<$numParts;$i+=2){
                if($parts[$i]<-90 || $parts[$i]>90){ return false; }
                if($parts[$i+1]<-180 || $parts[$i+1]>180){ return false; }
            }
        }
        return true;
    }

}