<?php class baseobject implements crudable {

	protected static array $fields=array();
	protected static array $fieldLabels=array();
	protected static array $helpTexts=array();
	protected static array $aliasedColumns=array(); //Returned in $adminSelect/$normalSelect but NOT in called class's columns

	public static function getFieldValidations(): array {
		return static::$fields; //static, not self - use the child class's.
	}
	public static function getFieldHelpTexts(): array {
		return static::$helpTexts; //static, not self - use the child class's.
	}
	public static function getFieldLabels(): array {
		return static::$fieldLabels; //static, not self - use the child class's.
	}
    public static function getAliasedColumns(): array {
        return static::$aliasedColumns; //static, not self - use the child class's.
    }

    public static string $defaultSortOrder='name ASC';

	/**
	 * The terms used where this class is included in a "get by name like" search.
	 * By default, this is the name property. However, in some cases it may be useful
	 * to override this so that search is done on a more relevant property.
	 * @var string
	 */
	protected static string $searchClause='[[classname]].name';
	
	protected static string $adminSelect= /** @lang text */
        'SELECT SQL_CALC_FOUND_ROWS [[classname]].*,project.name AS projectname, project.isarchived AS isarchived FROM [[classname]], project WHERE project.id=[[classname]].projectid ';
	protected static string $normalSelect= /** @lang text */
        'SELECT SQL_CALC_FOUND_ROWS [[classname]].*,project.name AS projectname, project.isarchived AS isarchived FROM [[classname]], project WHERE project.id=[[classname]].projectid ';

    /**
     * Return the SQL restricting records to those on which the user has the specified permission
     * @param string $accessType One of "read", "update", "delete"
     * @param bool $forceSharedProject
     * @return string
     * @throws BadRequestException
	 * @throws ServerException
     */
	protected static function getProjectClause(string $accessType, bool $forceSharedProject=false): string {
		return database::getProjectClause($accessType, $forceSharedProject);
	}

    /**
     * Creates a record in the baseobject table and returns its ID.
     * @param int $projectId The ID of the project for the new record
     * @param string $className
     * @return int The database ID of the baseobject row
     * @throws BadRequestException
     * @throws ForbiddenException if the user can read but not create records in the project
     * @throws NotFoundException if the project does not exist or the user cannot read it
     * @throws ServerException
     */
	protected static function createBaseObject(int $projectId, string $className): int {
		//$className=get_called_class();
		if(empty($projectId)){
			throw new BadRequestException('Project ID not provided.');
		}
		if(!session::isAdmin()){
			$forceSharedProject=(isset($className::$sharedProjectOverride) && $className::$sharedProjectOverride);
			$projects=session::getProjectPermissions('create',$forceSharedProject);
			if(!in_array($projectId,$projects)){
				session::refreshProjectPermissions();
				$projects=session::getProjectPermissions('create',$forceSharedProject);
			}
			if(!in_array($projectId,$projects)){
			    $projects=session::getProjectPermissions('read',$forceSharedProject);
				if(!in_array($projectId,$projects)){
					throw new NotFoundException("Either the project does not exist or you do not have permission to see it");
				} else {
					throw new ForbiddenException("You do not have permission to create in that project");
				}
			}
		}
		$sqlStatement='INSERT INTO baseobject(projectid,objecttype,creator,createtime) VALUES(:projectid,:objecttype,:userid,:now)';
		$now=new DateTime();
		$now->setTimezone(new DateTimeZone('GMT'));
		$params=array(
			':projectid'=>$projectId,
			':objecttype'=>$className,
			':userid'=>session::getUserId(),
			':now'=>$now->format('Y-m-d H:i:s')
		);
		database::query($sqlStatement,$params);
		return database::getLastInsertId();
	}

	/**
	 * @throws ServerException
	 * @throws BadRequestException
	 * @throws NotFoundException
	 */
	public static function getUuid(int $id, bool $setIfNull=false): ?string {
		$className=get_called_class();
		$obj=static::getById($id);
		if(!$obj){
			throw new NotFoundException("No $className with ID $id");
		}
		if(empty($obj['uuid'])){
			if(!$setIfNull){ return null; }
			$obj['uuid']=static::setUuid($id);
		}
		return $obj['uuid'];
	}

	/**
	 * @throws ServerException
	 * @throws NotFoundException
	 * @throws BadRequestException
	 */
	public static function setUuid(int $id, $uuid=null): string {
		$className=get_called_class();
		$obj=static::getById($id);
		if(!$obj){
			throw new NotFoundException("No $className with ID $id");
		}
		if(!empty($obj['uuid'])){ return $obj['uuid']; }
		if(!$uuid){
			$uuid=baseobject::generateUuid();
		}
		try {
			database::query(
				"UPDATE $className SET uuid=:uuid WHERE id=:id",
				[':uuid'=>$uuid, ':id'=>$id]
			);
		} catch (ServerException $e){
			throw new ServerException("No uuid column in $className? ".$e->getMessage());
		}
		return $uuid;
	}

	public static function generateUuid(): string {
		$timestamp = intval(microtime(true) * 1000);

		return sprintf('%02x%02x%02x%02x-%02x%02x-%04x-%04x-%012x',
			// first 48 bits are timestamp based
			($timestamp >> 40) & 0xFF,
			($timestamp >> 32) & 0xFF,
			($timestamp >> 24) & 0xFF,
			($timestamp >> 16) & 0xFF,
			($timestamp >> 8) & 0xFF,
			$timestamp & 0xFF,

			// 16 bits: 4 bits for version (7) and 12 bits for rand_a
			mt_rand(0, 0x0FFF) | 0x7000,

			// 16 bits: 4 bits for variant where 2 bits are fixed 10 and next 2 are random to get (8-9, a-b)
			// next 12 are random
			mt_rand(0, 0x3FFF) | 0x8000,
			// random 48 bits
			mt_rand(0, 0xFFFFFFFFFFFF),
		);
	}

	public static function getByUuid(string $uuid): ?array {
		return static::getBy('uuid',$uuid);
	}

	/**
	 * Returns the record with the specified ID.
	 * @param int $id The ID of the desired record
	 * @return array|null The record
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getById(int $id): ?array {
		return static::getBy('id', $id);
	}

	/**
	 * Returns the record with the specified name.
	 * @param string $name The name of the desired record
	 * @return array|null The record
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getByName(string $name): ?array {
		return static::getBy('name',$name);
	}

	/**
	 * @throws ServerException
	 * @throws BadRequestException
	 */
	private static function getBy(string $key, string $value): ?array {
		$className=get_called_class();
		if(session::isAdmin()){
			$sqlStatement=static::$adminSelect;
		} else {
			$sqlStatement=static::$normalSelect;
		}
		$sqlStatement.=" AND [[classname]].$key=:$key";
		$sqlStatement=str_replace('[[classname]]', $className, $sqlStatement);
		$sqlStatement.=static::getProjectClause('readOne');
		$params=array(":$key"=>$value);
		return database::queryGetOne($sqlStatement,$params);
	}

	/**
	 * @param string $searchTerms
	 * @param array $request
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getByNameLike(string $searchTerms, array $request=array()): ?array {
		$className=get_called_class();
		if(session::isAdmin()){
			$sqlStatement=static::$adminSelect;
		} else {
			$sqlStatement=static::$normalSelect;
		}
		$searchTerms=strtolower($searchTerms);
		$sqlStatement=str_replace('SELECT ', 'SELECT "[[classname]]" AS objecttype, ', $sqlStatement);
		$sqlStatement.=' AND LOWER([[classname]].name) LIKE :terms ';
		$sqlStatement=str_replace('[[classname]]', $className, $sqlStatement);
		$sqlStatement.=static::getProjectClause('read');
		$sqlStatement.=' ORDER BY INSTR(LOWER([[classname]], :terms2) '; 
		$sqlStatement.=database::getLimitClause($request);
		$params=array(
				':terms'=>'%'.$searchTerms.'%',
				':terms2'=>'%'.$searchTerms.'%'
		);
		return database::queryGetAll($sqlStatement,$params);
	}

	/**
	 * @param string $searchTerms
	 * @param array $request
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static final function getByNameLikeMultiClass(string $searchTerms, array $request=[]): ?array {
		if(!isset($request['classes'])){
			throw new BadRequestException('Specify classes to search for in querystring, e.g., ?classes=type1,type2,type3');
		}
		$originalSearchTerms=$searchTerms;
		$searchTerms=strtolower($searchTerms);
		//Replace UI wildcards with SQL ones
        $searchTerms=str_replace('?','_', $searchTerms);
        $searchTerms=str_replace('*','%', $searchTerms);

        //Create wildcarded inner search term, replacing potentially troublesome double wildcards
        $innerSearchTerms='%'.$searchTerms.'%';
        $innerSearchTerms=str_replace('%%','%', $innerSearchTerms);
        $innerSearchTerms=str_replace('%_','%', $innerSearchTerms);
        $innerSearchTerms=str_replace('_%','%', $innerSearchTerms);

		$classes=explode(',', $request['classes']);
		$parts=array();
		$params=array();
		$counter=1;
		foreach($classes as $className){
			if(!class_exists($className)){
				throw new BadRequestException('Class '.$className.' does not exist');
			}
			if('project'===$className){
				$sqlStatement='SELECT "project" AS objecttype, id, name, id AS projectid, name AS projectname, isarchived FROM project WHERE LOWER(name) LIKE LOWER(:terms'.$counter.')';
				if(!session::isAdmin()){
					$projectIds=session::getReadProjects();
					$sqlStatement.=' AND id IN('.implode(',',$projectIds).')';
				}
				$parts[]=$sqlStatement;
				$params[':terms'.$counter]=$innerSearchTerms;
				continue;
			}
			if(session::isAdmin()){
				$sqlStatement=static::$adminSelect;
			} else {
				$sqlStatement=static::$normalSelect;
			}

			$searchClause = $className::$searchClause ?? baseobject::$searchClause;
			
			$sqlStatement=str_replace('SQL_CALC_FOUND_ROWS', '', $sqlStatement);
			$sqlStatement=str_replace('SELECT ', 'SELECT "[[classname]]" AS objecttype, ', $sqlStatement);
			//$sqlStatement=str_replace('[[classname]].*', '[[classname]].id, [[classname]].name, [[classname]].projectid', $sqlStatement);
			$sqlStatement=str_replace('[[classname]].*', '[[classname]].id, '.$searchClause.' AS name, [[classname]].projectid', $sqlStatement);
			//$sqlStatement.=' AND LOWER([[classname]].name) LIKE LOWER(:terms'.$counter.') ';
			$sqlStatement.=' AND LOWER('.$searchClause.') LIKE LOWER(:terms'.$counter.') ';
			$sqlStatement=str_replace('[[classname]]', $className, $sqlStatement);
			$sqlStatement.=forward_static_call_array(array($className, 'getProjectClause'), array('read'));
			$parts[]=$sqlStatement;
			$params[':terms'.$counter]=$innerSearchTerms;
			$counter++;
		}

		$counter=1;
		$cases='';
		foreach ($classes as $c){
		    $cases.=' WHEN objecttype="'.$c.'" THEN '.$counter;
		    $counter++;
        }
        /** @noinspection SqlResolve */
        $sqlStatement='SELECT objecttype,id,name,projectid,projectname FROM ('.
		    implode(' UNION ALL ',$parts).
		    ') AS combinedresults ORDER BY CASE '.$cases.' END, INSTR(LOWER(name), :outerterm), objecttype, LOWER(name)';
		$sqlStatement.=database::getLimitClause($request);
		$params[':outerterm']=$searchTerms;
		$result=database::queryGetAll($sqlStatement,$params);
		$result['searchterms']=$originalSearchTerms;
		return $result;
	}

    /**
     * Generates the SQL condition for whether objects can be seen in a multi-class search. The default implementation
     * returns database::getProjectClause("read"), something like ' AND project.id IN(1,3,4) '. Any overriding implementation
     * must produce similar SQL for the search query to be valid.
     * @return string
     * @throws BadRequestException
	 * @throws ServerException
     */
	public static function getReadConditionsForMultiClassSearch(): string {
		return static::getProjectClause('read');
	}

	/**
	 * Returns the records with the specified property.
	 * @param string $key The name of the property
	 * @param string $value The value of the property
	 * @param array $request The request object, used for filtering, limit clause, etc.
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getByProperty(string $key, string $value, array $request=[]): ?array {
		$className=get_called_class();
		//verify key in fields array
		$keys=array_keys(self::getFieldValidations());
        $aliasedColumns=static::getAliasedColumns();

        if(!in_array($key, $keys) &&'projectid'!=$key && !in_array($key, $aliasedColumns)){
			throw new BadRequestException('Property '.$key.' not recognised on '.$className);
		}
		if(session::isAdmin()){
			$sqlStatement=static::$adminSelect;
		} else {
			$sqlStatement=static::$normalSelect;
		}
        if(in_array($key,$aliasedColumns)){
            $sqlStatement.=" AND $key<=>:$key ";
        } else {
            $sqlStatement.=" AND [[classname]].$key<=>:$key ";
        }
		$sqlStatement=str_replace('[[classname]]', $className, $sqlStatement);
		$sqlStatement.=static::getProjectClause('read');
        $sqlStatement.=database::getFilterClause($request, $className);
        $sqlStatement.=database::getOrderClause($request, $className);
		$sqlStatement.=database::getLimitClause($request);
		$params=array(":$key"=>$value);
		return database::queryGetAll($sqlStatement,$params);
	}

	/**
	 * Returns the records with the specified property.
	 * @param array $keyValuePairs
	 * @param array $request The request object, used for filtering, limit clause, etc.
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getByProperties(array $keyValuePairs, array $request=[]): ?array {
		$className=get_called_class();
		if(session::isAdmin()){
			$sqlStatement=static::$adminSelect;
		} else {
			$sqlStatement=static::$normalSelect;
		}
		$keys=array_keys(self::getFieldValidations());
        $aliasedColumns=self::getAliasedColumns();
		$params=array();
		foreach($keyValuePairs as $key=>$value){
            if(!in_array($key, $keys) &&'projectid'!=$key && !in_array($key, $aliasedColumns)){
				throw new BadRequestException('Property '.$key.' not recognised on '.$className);
			}
            if(in_array($key,$aliasedColumns)){
                $sqlStatement.=" AND $key<=>:$key ";
            } else {
                $sqlStatement.=" AND [[classname]].$key<=>:$key ";
            }
			$sqlStatement=str_replace('[[classname]]', $className, $sqlStatement);
			$params[":$key"]=$value;
		}
		$sqlStatement.=static::getProjectClause('read');
        $sqlStatement.=database::getFilterClause($request, $className);
		$sqlStatement.=database::getOrderClause($request, $className);
		$sqlStatement.=database::getLimitClause($request);
		return database::queryGetAll($sqlStatement,$params);
	}

	/**
	 * Returns all records, subject to any limits or filters in the request.
	 * @param array $request The request object, used for filtering, limit clause, etc.
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getAll(array $request=[]): ?array {
		$className=get_called_class();
		//verify key in fields array
		if(session::isAdmin()){
			$sqlStatement=static::$adminSelect;
		} else {
			$sqlStatement=static::$normalSelect;
		}
		//$sqlStatement='SELECT x.*,project.name AS projectname, project.isarchived AS isarchived FROM '.$className.' AS x, project WHERE project.id=x.projectid';
		$sqlStatement=str_replace('[[classname]]', $className, $sqlStatement);
		$sqlStatement.=static::getProjectClause('read');
        $sqlStatement.=database::getFilterClause($request, $className);
		$sqlStatement.=database::getOrderClause($request, $className);
		$sqlStatement.=database::getLimitClause($request);
		$params=array();
		return database::queryGetAll($sqlStatement,$params);
	}

	/**
	 * Gets the files associated with the record.
	 * @param int $id The ID of the parent record
	 * @param array $request
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getFiles(int $id, array $request=[]): ?array {
        return file::getByProperty('parentid',$id,$request);
	}

	/**
	 * Gets the notes associated with the record.
	 * @param int $id
	 * @param array $request
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
    public static function getNotes(int $id, array $request=[]): ?array {
        $request['all']='1';
        $request['sortby']='entered';
        return note::getByProperty('parentid',$id,$request);
    }

	/**
	 * Gets the audio recordings associated with the record.
	 * @param int $id
	 * @param array $request
	 * @return array|null
	 * @throws BadRequestException
	 * @throws ServerException
	 */
    public static function getRecordings(int $id, array $request=[]): ?array {
        $request['all']='1';
        $request['sortby']='id';
        $request['sortdescending']='yes';
        return audiorecording::getByProperty('parentid',$id,$request);
    }

	/**
	 * Forces the files, notes, and audio recordings attached to an object to have the same project ID
	 * as that object.
	 * @param int $parentId
	 * @throws BadRequestException
	 * @throws NotFoundException
	 * @throws ServerException
	 */
    public static function updateFileNoteAndRecordingProjectIds(int $parentId): void {
        file::setProjectIdsToMatchParent($parentId);
        note::setProjectIdsToMatchParent($parentId);
        audiorecording::setProjectIdsToMatchParent($parentId);
    }

    /**
     * Creates a new record
     * @param array $request
     * @return array
     * @throws BadRequestException
     * @throws ForbiddenException
     * @throws NotFoundException
     * @throws ServerException
     */
	public static function create(array $request=[]): array {
	    $className=get_called_class();
	    return baseobject::doCreate($request, $className);
	}

	/**
	 * @param array $request
	 * @param string $className
	 * @return array
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws NotFoundException
	 * @throws ServerException
	 */
	public static function createByClassName(array $request, string $className): array {
	    return static::doCreate($request, $className);
	}

	/**
	 * @param array $request
	 * @param string $className
	 * @return array
	 * @throws BadRequestException
	 * @throws ForbiddenException
	 * @throws NotFoundException
	 * @throws ServerException
	 */
	private static function doCreate(array $request, string $className): array {
		if(null==$className){ $className=get_called_class(); }
		$fields=array();
		if(!isset($request['projectid'])){
			throw new BadRequestException('Project ID not provided.');
		}
		$canCreateInProject=project::canCreateInProject($request['projectid']);
		if(!$canCreateInProject){
			if(!project::canReadProject($request['projectid'])){
				throw new NotFoundException('Project not found');
			}
			throw new ForbiddenException('You cannot create in that project');
		}
		$fieldValidations=forward_static_call($className.'::getFieldValidations');
		foreach($fieldValidations as $fname=>$validations){
			if(isset($request[$fname])){
				validator::validate($fname, $request[$fname], $validations);
				$fields[]=$fname;
			} else {
				validator::validate($fname, null, $validations);
			}
		}
		$keys='`'.implode('`,`', $fields).'`';
		$values=':'.implode(',:',$fields);
		static::beforeCreateHook($request, $className);
		$baseObjectId=self::createBaseObject($request['projectid'], $className);
        /** @noinspection SqlInsertValues */
        /** @noinspection SqlResolve */
        $sqlStatement='INSERT INTO '.$className.'(id,projectid,'.$keys.') VALUES(:id,:projectid,'.$values.')';
		$params=array(':id'=>$baseObjectId, ':projectid'=>$request['projectid']);
		foreach($fields as $f){
			if(isset($request[$f])){
				$params[':'.$f]=$request[$f];
			}
		}
		database::query($sqlStatement,$params);
		if('file'==$className){
            $created=array('id'=>$baseObjectId, 'projectid'=>$request['projectid']);
		    $ret=array('type'=>$className, 'created'=>$created);
		} else {
		    $created=forward_static_call(array($className,'getById'), $baseObjectId);
            $ret=array('type'=>$className, 'created'=>$created);
        }
        static::afterCreateHook($created, $className);
		return $ret;
	}

    /**
     * Updates one or more fields for a given record. Fields are updated if the field name is found in the fields array of the calling class.
     * @param int $id The ID of the record
     * @param array $request
     * @return array The updated record
     * @throws BadRequestException if one or more fields is invalid
     * @throws ForbiddenException if user can read but not update the record
     * @throws ServerException
     * @throws NotFoundException
     */
	public static function update(int $id,array $request=[]): array {
		$className=get_called_class();
		if(isset($request['_classname'])){
		    $className=$request['_classname'];
		    unset($request['_classname']);
		}

		if(!preg_match('/^[A-Za-z0-9_-]*$/',$className)){
			throw new BadRequestException('Bad class name got to baseobject::update');
		}
		if(!session::isAdmin() && !$className::canUpdate($id)){
		    $forceSharedProject=(isset($className::$sharedProjectOverride) && $className::$sharedProjectOverride);
            /** @noinspection SqlResolve */
            $sqlStatement='SELECT projectid FROM '.$className.',project 
					WHERE '.$className.'.projectid=project.id 
						AND '.$className.'.id=:id '.static::getProjectClause('update',$forceSharedProject);
			$params=array(':id'=>$id);
			$result=database::queryGetOne($sqlStatement,$params);
			if(empty($result)){
				//id doesn't exist, or no update permissions
				self::getbyid($id); //will throw NotFoundException if not found or no read permissions
				//still here? No exception was thrown above. Object exists, we can read it, we can't update it.
				throw new ForbiddenException("You do not have permission to update that record");	
			}
		}

        //now we can get on with it
        $fieldValidations=forward_static_call_array(array($className, 'getFieldValidations'), []);
		foreach($fieldValidations as $field=>$validations){
			if(!preg_match('/^[A-Za-z0-9_-]*$/',$field)){
				throw new BadRequestException('Bad field name got to baseobject::update');
			}
		}
		static::beforeUpdateHook($id, $request, $className);
        foreach($fieldValidations as $field=>$validations){
			if(isset($request[$field])){
				validator::validate($field,$request[$field],$validations); //will throw exception if invalid
				$params=array(
					':id'=>$id,
					':newvalue'=>$request[$field]
				);			
				database::query('UPDATE `'.$className.'` SET `'.$field.'`=:newvalue WHERE id=:id', $params);
			}
		}
		//Set last edit user and time
		$now=new DateTime();
		$now->setTimezone(new DateTimeZone('GMT'));
		$params=array(
			':id'=>$id,
			':lasteditor'=>session::getUserId(),
			':lastedittime'=>$now->format('Y-m-d H:i:s')
		);
		database::query('UPDATE baseobject SET lasteditor=:lasteditor, lastedittime=:lastedittime WHERE id=:id', $params);
		static::afterUpdateHook($id, $className);
		return array('updated'=>static::getById($id));
	}


    /**
     * Deletes the record with the specified ID, or throws an exception. Note that this does an SQL DELETE of the row in the
     * baseobject table ONLY - it relies on the correct ON DELETE CASCADE behaviour being set in the child table definitions.
     * @param int $id The database ID of the object to delete
     * @return array
     * @throws BadRequestException
     * @throws ForbiddenException if the user can read but not delete the record
     * @throws NotFoundException
     * @throws ServerException
     */
	public static function delete(int $id): array {
	    $className=get_called_class();
        /** @noinspection PhpUndefinedMethodInspection */
        if(!session::isAdmin() && !$className::canDelete($id)){
			$forceSharedProject=(isset($className::$sharedProjectOverride) && $className::$sharedProjectOverride);
            /** @noinspection SqlResolve */
            $sqlStatement='SELECT projectid FROM '.$className.',project
					WHERE '.$className.'.projectid=project.id
						AND '.$className.'.id=:id '.static::getProjectClause('delete',$forceSharedProject);
	        $params=array(':id'=>$id);
	        $result=database::queryGetOne($sqlStatement,$params);
	        if(empty($result)){
	            //id doesn't exist, or no delete permissions
	            self::getbyid($id); //will throw NotFoundException if not found or no read permissions
	            //still here? No exception was thrown above. Object exists, we can read it, we can't delete it.
	            throw new ForbiddenException("You do not have permission to delete that record");
	        }
	    }
        $object=static::getById($id);
        static::beforeDeleteHook($object, $className);
		$sqlStatement='DELETE FROM baseobject WHERE id=:id';
		$params=array(':id'=>$id);
		database::query($sqlStatement,$params);
		//if affected rows, OK, else
		//get by id
        static::afterDeleteHook($object, $className);
		return array('deleted'=>$id);
	}

	/**
	 * Whether the current user can create records in any project.
	 * This default implementation returns true if the user (a) is an administrator, or (b) has "create" permissions on any project.
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function canCreate(): bool {
		return session::isAdmin() || !empty(session::getCreateProjects());
	}

    /**
     * Whether the current user can update the record with this ID.
     * This default implementation returns true if the user (a) is an administrator, or (b) has update permissions on the record's project.
     * @param $id
     * @return bool
     * @throws BadRequestException
     * @throws ServerException
	 */
	public static function canUpdate(int $id): bool {
		$projects=session::getUpdateProjects();
		$item=static::getById($id);
		return in_array($item['projectid'], $projects);
	}


	/**
	 * Whether the current user can delete the record with this ID.
	 * This default implementation returns true if the user (a) is an administrator, or (b) has "delete" permissions on the record's project.
	 * @param int $id
	 * @return bool
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function canDelete(int $id): bool {
		if(session::isAdmin()){ return true; }
		if(!config::get('delete_allowed')){ return false; }
		$projects=session::getDeleteProjects();
		$item=static::getById($id);
		if(!$item){ return false; }
		return in_array($item['projectid'], $projects);
	}

    /**
     * Calls a class method with the supplied arguments, throwing a BadRequestException if class or method does not exist.
     * @param string $className The class name
     * @param string $methodName The method name
     * @param array $args The arguments to pass to the method
     * @return mixed The results of the method call
     * @noinspection PhpUnused*@throws BadRequestException if the class or method does not exist
     * @throws BadRequestException
     */
	public static function callOrThrow(string $className, string $methodName, array $args): mixed {
	    if(!class_exists($className)){
	        throw new BadRequestException('Class '.$className.' does not exist');
	    }
	    if(!method_exists($className, $methodName)){
	        throw new BadRequestException('Class '.$className.': Method '.$methodName.' does not exist');
	    }
	    return forward_static_call_array(array($className, $methodName), $args);
	}
	
	public static function canMakeQrCodes(): bool {
	    if(!extension_loaded('gd')){ return false; }
	    if(PHP_MAJOR_VERSION<7){ return false; }
	    if(PHP_MAJOR_VERSION==7 && PHP_MINOR_VERSION<2){ return false; }
	    return true;
	}

    /**
     * Creates a QR code to the specified URL, and attaches it to the object as a file.
     * @param $id int The ID of the object.
     * @param $url string The URL to link to.
     * @param $filename string The filename to use for the QR code.
     * @param $description string The description of the code, to show in the parent object's Files tab
     * @return array|bool
     * @throws BadRequestException
     * @throws ForbiddenException
     * @throws NotFoundException
     * @throws ServerException
     */
	public static function makeQrCode(int $id, string $url, string $filename, string $description): bool|array {
	    if(!static::canMakeQrCodes()){ return false; }
	    $saveTo=rtrim(config::get('core_filestore'),'/').'/qr_'.str_replace('.','_',$filename).'_'.str_replace(' ','_',microtime()).'.png';

	    $generator=new barcode_generator();
	    $image=$generator->render_image('qr-m', $url, array());
	    imagepng($image, $saveTo);
	    imagedestroy($image);
	    $generator=null;
	    
 	    session::becomeAdmin();
	    $file=file::createFromTempFile(array(
	        'parentid'=>$id,
	        'mimetype'=>'image/png',
	        'filename'=>$filename,
	        'description'=>$description,
	        'tempfile'=>$saveTo
	    ));
	    session::revertAdmin();
	    return $file;
	}

    //Hook functions

	/**
	 * @param array $request
	 * @param string|null $className
	 * @throws ServerException
	 */
    public static function beforeCreateHook(array $request, string $className=null): void {
        static::doHook('before','create',array(
            'request'=>$request
        ), $className);
    }

	/**
	 * @param array $createdObject
	 * @param null $className
	 * @throws ServerException
	 */
    public static function afterCreateHook(array $createdObject, $className=null): void {
        static::doHook('after','create',array(
            'created'=>$createdObject,
            'id'=>$createdObject['id']
        ), $className);
    }

	/**
	 * @param int $id
	 * @param array $request
	 * @param null $className
	 * @throws ServerException
	 */
    public static function beforeUpdateHook(int $id, array $request, $className=null): void {
        static::doHook('before','update',array(
            'id'=>$id,
            'request'=>$request
        ), $className);
    }

	/**
	 * @param int $id
	 * @param string|null $className
	 * @throws BadRequestException
	 * @throws ServerException
	 */
    public static function afterUpdateHook(int $id, string $className=null): void {
        $updatedObject=static::getById($id);
        static::doHook('after','update',array(
            'updated'=>$updatedObject,
            'id'=>$id
        ), $className);
    }

	/**
	 * @param array|null $objectToDelete
	 * @param string|null $className
	 * @throws ServerException
	 */
    public static function beforeDeleteHook(?array $objectToDelete, string $className=null): void {
		if(!$objectToDelete){ return; }
        static::doHook('before','delete',array(
            'toDelete'=>$objectToDelete,
			//TODO line below causes warning on containercontent delete (I think)
            'id'=>$objectToDelete['id']
        ), $className);
    }

	/**
	 * @param array|null $deletedObject
	 * @param string|null $className
	 * @throws ServerException
	 */
    public static function afterDeleteHook(?array $deletedObject, string $className=null): void {
		if(!$deletedObject){ return; }
        static::doHook('after','delete',array(
            'deleted'=>$deletedObject,
            'id'=>$deletedObject['id']
        ), $className);
    }

    /**
     * Performs actions before calling the IceBear API method.
     * @param $when string 'before' or 'after'
     * @param $action string What is being done (create,update,delete)
     * @param $args array The arguments to be made available in the hook file
     * @param string|null $className
     * @throws ServerException
     */
    private static function doHook(string $when, string $action, array $args, string $className=null): void {
        if(null===$className){ $className=get_called_class(); }
        if(!in_array($when,array('before','after'))){
            throw new ServerException('Bad when in doHook: '.$when.' (should be one of before,after)');
        }
        $hookActions=array('create','update','delete');
        if(!in_array($action, $hookActions)){
            throw new ServerException('Bad action in doHook: '.$action.' (should be one of '.implode(',',$hookActions).')');
        }
        $filename=$className.'_'.$when.'_'.$action;
        if(!preg_match('/^[a-z]+_[a-z]+_[a-z]+$/', $filename)){
            throw new ServerException('Bad hook filename in doHook: '.$filename);
        }
        $hookFile=config::getWwwRoot().'/api/_hooks/'.$filename;
        if(file_exists($hookFile.'.php')){
			include_once($hookFile.'.php');
        }
        $count=1;
        while(file_exists($hookFile.'_'.$count.'.php')){
			include_once($hookFile.'_'.$count.'.php');
            $count++;
        }
    }

	/**
	 * @throws BadRequestException
	 * @throws ServerException
	 */
	public static function getFieldDetails(): ?array {
		$className=get_called_class();
		if(!preg_match('/^[a-z]+$/', $className)){ throw new ServerException('Bad table name in gettableconstraints: '.$className); }

		$fields=baseobject::callOrThrow($className,'getFieldValidations', []);
		$fieldNames=baseobject::callOrThrow($className,'getFieldLabels', []);
		$helpTexts=baseobject::callOrThrow($className,'getFieldHelpTexts', []);
		$result=database::queryGetOne('SHOW CREATE TABLE '.$className);
		if(!$result || !isset($result['Create Table'])){ throw new ServerException('No CREATE TABLE for table '.$className); }
		$lines=explode("\n",$result['Create Table']);
		$constraints=[];
		foreach($lines as $line){
			$constraint=[];
			$line=trim(str_replace('`','', $line));
			if(!str_starts_with($line, 'CONSTRAINT')){ continue; }
			$line=explode('FOREIGN KEY (', $line)[1];
			if(!$line){ throw new ServerException('Unrecognised constraint format'); }
			$parts=explode(') REFERENCES ', $line);
			$constraint['tableName']=$className;
			$columnName=$parts[0];
			$parts=explode(')', $parts[1]);
			$parts=explode('(', $parts[0]);
			$constraint['otherTable']=trim($parts[0]);
			$constraint['otherColumn']=trim($parts[1]);
			$constraints[$columnName]=$constraint;
		}

		$ret=[];
		foreach ($fields as $field=>$validations){
			$obj=['validations'=>$validations];
			if(isset($fieldNames[$field])){
				$obj['label']=$fieldNames[$field];
			} else {
				$obj['label']=$field;
			}
			if(isset($helpTexts[$field])){
				$obj['helpText']=$helpTexts[$field];
			}
			if(isset($constraints[$field])){
				$obj['otherTable']=$constraints[$field]['otherTable'];
				$obj['otherColumn']=$constraints[$field]['otherColumn'];
			}
			$ret[$field]=$obj;
		}
		if(isset($constraints['projectid'])){
			$ret['projectid']=[
				'label'=>'Project',
				'otherTable'=>'project',
				'otherColumn'=>'id'
			];
		}

		if(empty($constraints)){ return null; }
		return [ 'total'=>count($ret), 'rows'=>$ret ];
	}

}
