<?php class apikey {

	const INVALID_API_KEY='No matching API key found';
    const LOWEST_IP_CANNOT_BE_HIGHER_THAN_HIGHEST_IP = 'Lowest IP cannot be higher than highest IP';
    const SCOPE_NOT_AUTHORISED_FOR_THIS_KEY = 'Scope not authorised for this key';
    const CANNOT_CHANGE_API_KEY_SCOPE_AFTER_CREATION = 'Cannot change API key scope after creation.';
    const BAD_IP_ADDRESS = 'Bad IP address';
    const CANNOT_CHANGE_OTHER_FIELDS_WHILE_REGENERATING_THE_KEY = 'Cannot change other fields while regenerating the key';
    protected static array $fields = array(
        'name' => validator::ANY, //actually required but is auto generated
        'scope'=> validator::REQUIRED,
        'keyhash' => validator::ANY, //actually required but is auto generated
        'iplowest' => validator::IP4ADDRESS,
        'iphighest' => validator::IP4ADDRESS,
        'loglevel' => validator::INTEGER
    );

    protected static array $helpTexts = array();

    private static string $hashAlgorithm=PASSWORD_BCRYPT;

	/**
	 * @param int $id
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws ServerException
	 */
    public static function getById(int $id): ?array {
        session::requireAdmin();
        $ret=database::queryGetOne(
            'SELECT id, scope, iplowest, iphighest, loglevel FROM apikey WHERE id=:id',
            array(':id'=>$id)
        );
        if($ret){
            $ret['iplowest']=long2ip($ret['iplowest']);
            $ret['iphighest']=long2ip($ret['iphighest']);
        }
        return $ret;
    }

    /**
     * @param string $name
     * @return array|void
     * @throws ForbiddenException
     * @throws ServerException
     */
    public static function getByName(string $name): ?array {
        session::requireAdmin();
        throw new ServerException('getByName not implemented on apikey');
    }

	/**
	 * @param array $request
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws ServerException
	 */
    public static function getAll(array $request=[]): ?array {
        session::requireAdmin();
        $ret=database::queryGetAll(
            'SELECT SQL_CALC_FOUND_ROWS id, scope, iplowest, iphighest, loglevel FROM apikey'
		);
        if($ret && isset($ret['rows'])){
            foreach ($ret['rows'] as &$row){
                $row['iplowest']=long2ip($row['iplowest']);
                $row['iphighest']=long2ip($row['iphighest']);
            }
        }
        $ret['scopes']=static::getScopes();
        return $ret;
    }

    /**
     * @param string $key
     * @param string $value
     * @param array $request
     * @return void
     * @throws ForbiddenException
     * @throws ServerException
     */
    public static function getByProperty(string $key, string $value, array $request=[]): void {
        session::requireAdmin();
        throw new ServerException('getByProperty not implemented on apikey');
    }

    /**
     * @param array $keyValuePairs
     * @param array $request
     * @return void
     * @throws ForbiddenException
     * @throws ServerException
     */
    public static function getByProperties(array $keyValuePairs, array $request=[]): void {
        session::requireAdmin();
        throw new ServerException('getByProperties not implemented on apikey');
    }

    /**
     * @param $id
     * @param array $request
     * @return array
     * @throws BadRequestException
     * @throws ForbiddenException
     * @throws ServerException
     * @throws NotFoundException
     */
    public static function update($id, array $request=[]): array {
        session::requireAdmin();
        validator::validateAll($request, static::$fields, false);
        if(isset($request['keyhash'])){
            if(count(array_keys($request))!==1){
                throw new BadRequestException(self::CANNOT_CHANGE_OTHER_FIELDS_WHILE_REGENERATING_THE_KEY);
            }
            baseobject::beforeUpdateHook($id, $request, 'apikey');
            $result=static::regenerateKey($id);
            baseobject::afterUpdateHook($id, 'apikey');
            return array('updated'=>$result);
        }
        $key=static::getById($id);
        if(isset($request['scope'])){
            throw new BadRequestException(self::CANNOT_CHANGE_API_KEY_SCOPE_AFTER_CREATION);
        }
        if(!isset($request['iplowest'])){ $request['iplowest']=$key['iplowest']; }
        if(!isset($request['iphighest'])){ $request['iphighest']=$key['iphighest']; }
        foreach(static::$fields as $field=>$validations) {
            if (isset($request[$field])) {
                validator::validate($field, $request[$field], $validations); //will throw exception if invalid
            }
        }
        if(isset($request['iplowest']) || isset($request['iphighest'])){
            $request['iplowest']=ip2long($request['iplowest']);
            $request['iphighest']=ip2long($request['iphighest']);
            if(false===$request['iplowest'] || false===$request['iphighest']){
                throw new BadRequestException(self::BAD_IP_ADDRESS);
            }
            if($request['iphighest']<$key['iplowest']){
                throw new BadRequestException(self::LOWEST_IP_CANNOT_BE_HIGHER_THAN_HIGHEST_IP);
            }
        }
        if(isset($request['loglevel'])){
            $levels=Log::getLogLevels();
            $levels=array_column($levels, 'severity');
            if(!in_array(1*$request['loglevel'], $levels)){
                throw new BadRequestException('Bad log level: '.$request['loglevel']);
            }
        }
        $columns=[];
        $placeholders=[':id'=>$id];
        foreach (['loglevel','iplowest','iphighest'] as $column){
            if(isset($request[$column])){
                $columns[]="$column=:$column";
                $placeholders[':'.$column]=$request[$column];
            }
        }
        baseobject::beforeUpdateHook($id, $request, 'apikey');
        database::query('UPDATE apikey SET '.implode(',',$columns).' WHERE id=:id', $placeholders);
        baseobject::afterUpdateHook($id, 'apikey');
        return array('updated'=>static::getById($id));
    }

    /**
     * @param $id
     * @return array
     * @throws BadRequestException
     * @throws ForbiddenException
     * @throws ServerException
     */
    public static function regenerateKey(int $id): array {
        session::requireAdmin();
        $newKey=static::getKeyAndHash();
        //DO NOT attempt to rationalise this by calling ::update. It calls this, so it will go infinite!
        database::query(
            'UPDATE apikey SET keyhash=:hash WHERE id=:id',
            array(':hash'=>$newKey['hash'], ':id'=>$id)
        );
        $key=static::getById($id);
        $key['key']=$newKey['key'];
        return $key;
    }

    /**
     * @param array $request
     * @return array
     * @throws BadRequestException
     * @throws ForbiddenException
     * @throws ServerException
     */
    public static function create(array $request=[]): array {
        session::requireAdmin();
        validator::validateAll($request, static::$fields, true);
        if(!in_array($request['scope'], static::getScopes())){
            throw new BadRequestException('Invalid scope');
        }
        if(empty($request['iplowest'])){
            $request['iplowest']='0.0.0.0';
        }
        if(empty($request['iphighest'])){
            $request['iphighest']='255.255.255.255';
        }
        $request['iplowest']=ip2long($request['iplowest']);
        if(false===$request['iplowest']){
            throw new BadRequestException('Minimum IP is not valid');
        }
        $request['iphighest']=ip2long($request['iphighest']);
        if(false===$request['iphighest']){
            throw new BadRequestException('Maximum IP is not valid');
        }
        if($request['iphighest'] < $request['iplowest']){
            throw new BadRequestException(self::LOWEST_IP_CANNOT_BE_HIGHER_THAN_HIGHEST_IP);
        }
        if(!isset($request['loglevel'])){
            $request['loglevel']=Log::LOGLEVEL_INFO;
        } else {
            $levels=array_column(Log::getLogLevels(), 'severity');
            if(!in_array(1*$request['loglevel'], $levels)){
                throw new BadRequestException('Bad log level: '.$request['loglevel']);
            }
        }
        baseobject::beforeCreateHook($request,'apikey');
        //generate and hash the key
        $keyAndHash=static::getKeyAndHash();
        $key=$keyAndHash['key'];
        $keyHash=$keyAndHash['hash'];
        database::query(
            'INSERT INTO apikey(keyhash,scope,iplowest,iphighest,loglevel)
            VALUES(:keyhash,:scope,:iplowest,:iphighest,:loglevel)',
            array(
                ':keyhash'=>$keyHash,
                ':scope'=>$request['scope'],
                ':iplowest'=>$request['iplowest'],
                ':iphighest'=>$request['iphighest'],
                ':loglevel'=>$request['loglevel']
            )
        );
        $created=apikey::getById(database::getLastInsertId());
        $created['key']=$key;
        baseobject::afterCreateHook($created,'apikey');
        return array(
            'type'=>'apikey',
            'created'=>$created
        );
    }

	/**
	 * Deletes the API key.
	 * @param int $id
	 * @return array
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws NotFoundException
	 * @throws ServerException
	 */
    public static function delete(int $id): array {
        session::requireAdmin();
        $apiKey=apikey::getById($id);
        if(!$apiKey){ throw new NotFoundException('No API key with that ID'); }
        baseobject::beforeDeleteHook($apiKey,'apikey');
        database::query(
            'DELETE FROM apikey WHERE id=:id',
            array(':id'=>$id)
        );
        baseobject::afterDeleteHook($apiKey,'apikey');
        return array('deleted'=>$id);
    }

	/**
	 * @param string $keyString
	 * @param string $scope
	 * @throws AuthorizationRequiredException
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function validate(string $keyString, string $scope): void {
		$message=static::doValidation($keyString,$scope);
		if(!empty($message)){
			throw new AuthorizationRequiredException($message);
		}
	}

	/**
	 * @throws ServerException
	 * @throws BadRequestException
	 */
	public static function isValid(string $keyString, string $scope): bool {
		return empty(static::doValidation($keyString,$scope));
	}

	/**
	 * @throws ServerException
	 * @throws BadRequestException
	 */
	private static function doValidation(string $keyString, string $scope): ?string {
        $isValid=false;
        $keys=database::queryGetAll('SELECT * FROM apikey');
        if(!$keys){
            return 'No API keys defined';
        }
        foreach($keys['rows'] as $key){
            if(password_verify($keyString, $key['keyhash'])){
                if($scope!=$key['scope']){
                    return self::SCOPE_NOT_AUTHORISED_FOR_THIS_KEY;
                }
                if(isset($_SERVER['REMOTE_ADDR'])){
                    $ip=ip2long($_SERVER['REMOTE_ADDR']);
                    if(!static::ipIsValidForKey($ip, $key)){
                        return static::BAD_IP_ADDRESS;
                    }
                }
                $isValid=true;
                break;
            }
        }
		if(!$isValid){
			return static::INVALID_API_KEY;
		}
		return null;
    }

    /**
     * @throws ServerException
     * @throws BadRequestException
     */
    public static function ipIsValidForKey(string $ip, array $keyObject): bool {
        if(is_numeric($ip)){
            $ip=long2ip($ip);
        }
        if(!validator::isValid($ip, validator::IP4ADDRESS)){
            throw new BadRequestException('Bad or malformed IP address');
        }
        $lowest=$keyObject['iplowest'];
        $highest=$keyObject['iphighest'];
        if(!is_numeric($ip)){ $ip=ip2long($ip); }
        if(!is_numeric($lowest)){ $lowest=ip2long($lowest); }
        if(!is_numeric($highest)){ $highest=ip2long($highest); }
        if($ip<$lowest || $ip>$highest){
            return false;
        }
        return true;
    }

    /**
     * @throws ServerException
     * @throws BadRequestException
     * @throws AuthorizationRequiredException
     */
    public static function getLogLevel(string $key): int {
        $logLevel=0;
        $keys=database::queryGetAll('SELECT * FROM apikey');
        if(!$keys){
            throw new AuthorizationRequiredException('No API keys defined');
        }
        foreach($keys['rows'] as $row) {
            if(password_verify($key, $row['keyhash'])){
                $logLevel=$row['loglevel'];
                break;
            }
        }
        if(!$logLevel){
            throw new ServerException('No match found');
        }
        return 1*$logLevel;
    }

    /**
     * @return array
     * @throws ServerException
     */
    public static function getKeyAndHash(): array {
        try {
            $key = bin2hex(random_bytes(30));
        } catch (Exception) {
            throw new ServerException('Could not generate key: random_bytes threw Exception');
        }
        $keyHash=password_hash($key, static::$hashAlgorithm);
        return array('key'=>$key, 'hash'=>$keyHash);
    }

    /**
     * @return array
     */
    public static function getScopes(): array {
        $classNames=[];
        $deviceClasses=__DIR__.'/../device';
        $dir=dir($deviceClasses);
        $filename=$dir->read();
        while(!empty($filename)){
            if(preg_match('/.*\.class\.php$/', $filename)){
                $className=str_replace('.class.php', '', $filename);
                if('Device'!==$className){
                    $classNames[]=$className;
                }

            }
            $filename=$dir->read();
        }
        return $classNames;
    }

}