<?php

/**
 * Discuss with Andre:
 *  - what does a bad/rejected image response look like? Multiple errors?
 *  - where are the "imageId" in the success response coming from? Are these mine? (Don't want to keep track of remote image id)
 *  - GET /scorings:
 *    - add "in the last x minutes" (can use same URL over and over, without calculating startDate)
 *    - add "completed" status (get both succeeded and failed evaluations)
 *
 *
 */

class IceStorm {

	//TODO make all methods and properties static
	//TODO namespace
	//TODO add log class to namespace

	private array $config;
	private int $MAX_LOCKFILE_AGE_DEFAULT=30;

	/**
	 * @throws Exception
	 */
	public function __construct() {
		$this->config = $this->getConfig();
	}

	/**
	 * Queue an image for scoring by IceStorm.
	 * @param string $imagePath The path to the image file
	 * @param int $localImageId A local database ID for the image, to enable matching with results later. If not
	 * specified, a microsecond timestamp is used.)
	 * @param array $metadata Any metadata that should be passed to the service and returned with the result. This is
	 * particularly important if no database ID is provided; you will need to identify the image in some other way when
	 * the result is retrieved.
	 * @return string The location of the metadata JSON file.
	 * @throws Exception
	 */
	public function queueUnscoredImage(string $imagePath, int $localImageId=0, array $metadata=[]) {
		try {
			$directory=$this->config['unscoredImagesDirectory'];
			$data=[
				'clientImageId'=>$localImageId,
				'clientImagePath'=>$imagePath,
				'clientMetadata'=>$metadata
			];
			return $this->checkImageExistsAndWriteMetadataJSON($data, $directory);
		} catch (Exception $exception){
			$this->logAndRethrowException($exception);
		}
	}

	/**
	 * Queue a human-scored image for submission to IceStorm's training set.
	 * @param string $imagePath The path to the image file
	 * @param string $humanScoreName The human-friendly name of the image score, e.g., "Crystals" or "Heavy precipitate"
	 * @param int $localImageId A local database ID for the image, to enable matching with results later. If not
	 * specified, a microsecond timestamp is used.)
	 * @param array $metadata Any metadata that should be passed to the service and returned with the result.
	 * @return string The location of the metadata JSON file.
	 * @throws Exception
	 */
	public function queueHumanScoredImage(string $imagePath, string $humanScoreName, int $localImageId=0, array $metadata=[]) {
		try {
			$directory=$this->config['humanScoredImagesDirectory'];
			$data=[
				'clientImageId'=>$localImageId,
				'clientImagePath'=>$imagePath,
				'clientHumanScoreName'=>$humanScoreName,
				'clientMetadata'=>$metadata
			];
			return $this->checkImageExistsAndWriteMetadataJSON($data, $directory);
		} catch (Exception $exception){
			$this->logAndRethrowException($exception);
		}
	}

	/**
	 * Determines some image attributes, writes them to the metadata, and writes the metadata to disk as JSON.
	 * @param array $metadata Metadata about the image
	 * @param string $directory The directory where the metadata should be written
	 * @return string The location on disk of the written JSON
	 * @throws Exception if there is nothing at the "clientImagePath" in $metadata
	 * @throws Exception if the image is corrupt or not an image
	 * @throws Exception if the JSON cannot be written to disk
	 */
	private function checkImageExistsAndWriteMetadataJSON(array $metadata, string $directory): string{
		if(!$this->canWriteInDirectory($directory)){
			throw new Exception("Cannot write in $directory");
		}
		if(!$metadata['clientImageId']){
			$metadata['clientImageId']=str_replace('.','',microtime(true));
		}
		$imagePath=$metadata['clientImagePath'];
		if(!file_exists($imagePath)){
			throw new Exception("Image does not exist at $imagePath");
		}
		$size=@getimagesize($imagePath);
		if(!$size){
			throw new Exception("File at $imagePath is corrupt or is not an image");
		}
		if($size[0]){ $metadata["pixelWidth"]=$size[0]; }
		if($size[1]){ $metadata["pixelHeight"]=$size[1]; }
		if($size['mime']){ $metadata['mimeType']=$size['mime']; }
		$directory= rtrim($directory, '/') . '/';
		$json=json_encode($metadata);
		$path=$directory.$metadata['clientImageId'].'.json';
		if(!@file_put_contents($path, $json)){
			throw new Exception("Could not write image metadata to $path");
		}
		return $path;
	}

	/**
	 * @throws Exception
	 */
	public function submitUnscoredImages(): int {
		try {
			$directory=rtrim($this->config['unscoredImagesDirectory'],'/').'/';
			$lockfileName='submitUnscoredImages';
			Log::init($this->config['logLevel']);
			Log::info('submitUnscoredImages() called');
			$this->checkCurlAvailable();
			if(!$this->canWriteInDirectory($directory)){
				throw new Exception("Cannot write in $directory");
			}
			if(!$this->writeLockfile($lockfileName)){
				Log::warn('Lock file exists and is fresh');
				return 0;
			};
			$files=array_diff(scandir($directory),['.','..']);
			$imagesMetadata=[];
			foreach($files as $file){
				$content=@file_get_contents($directory.$file);
				if(!$content){
					Log::warn("Could not open $file");
					continue;
				}
				$data=json_decode($content,true);
				if(!$data){
					Log::warn("$file is not valid JSON");
					continue;
				}
				if(!isset($data['clientImageId'])){
					throw new Exception('No clientImageId in image metadata');
				}
				$imagesMetadata['image_'.$data['clientImageId']]=$data;
			}
			$this->submitImagesToEndpoint('',$imagesMetadata, $directory);
			$this->removeLockfile($lockfileName);
			return 1; //TODO number of submitted images
		} catch (Exception $exception){
			$this->removeLockfile($lockfileName);
			$this->logAndRethrowException($exception);
		}
	}

	/**
	 * @throws Exception
	 */
	public function submitHumanScoredImages(): bool {
		try {
			$directory=$this->config['humanScoredImagesDirectory'];
			$lockfileName='submitHumanScoredImages';
			Log::init($this->config['logLevel']);
			Log::debug('In submitUnscoredImages()');
			Log::info('Submitting unscored images to IceStorm...');
			$this->checkCurlAvailable();
			if(!$this->canWriteInDirectory($directory)){
				throw new Exception("Cannot write in $directory");
			}
			if(!$this->writeLockfile($lockfileName)){
				Log::warn('Lock file exists and is fresh');
				return 0;
			};

			$this->removeLockfile($lockfileName);
			return 1; //TODO number of submitted images
		} catch (Exception $exception){
			$this->removeLockfile($lockfileName);
			$this->logAndRethrowException($exception);
		}
	}

	/**
	 * Retrieves new image scores from IceStorm.
	 * @return array
	 * @throws Exception
	 */
	public function retrieveNewScores(): array {
		throw new Exception('Not implemented'); //TODO Implement
		try {
			Log::init($this->config['logLevel']);
			Log::info('Retrieving new scores');
			$this->checkCurlAvailable();

			return [];
		} catch (Exception $exception){
			$this->logAndRethrowException($exception);
		}
	}

	private function submitImagesToEndpoint($endpoint,array $imagesMetadata, $queueDirectory) {
		$postData=[];
//		$images=[];
		foreach ($imagesMetadata as $key=>$image) {
			if(!isset($image['clientImagePath'])){
				throw new Exception('Image metadata without clientImagePath');
			}
			$path=realpath($image['clientImagePath']);
			if(!file_exists($path)){
				Log::warn("Image does not exist on disk: $path");
				Log::warn("Not submitting metadata");
				unset($imagesMetadata[$key]);
			}
			$mimeType=isset($image['mimeType']) ? $image['mimeType'] : null ;
			$filename=end(explode(DIRECTORY_SEPARATOR, $path));
			$postData['image_'.$image['clientImageId']]=curl_file_create($path, $mimeType, $filename);
//			$images[]=curl_file_create($path, $mimeType, $filename);

		}
		if(!count(array_keys($imagesMetadata))){
			Log::warn('No images to submit after removing those that do not exist');
			return false;
		}
//		$postData['images']=$images;
		$postData['metadata']=json_encode($imagesMetadata);
		$apiRoot=rtrim($this->config['apiRoot'], '/').'/';
		$url=$apiRoot.trim($endpoint,'/');
		$url='https://www.postb.in/1771624352689-5584578774869';
		$url='https://echo.free.beeceptor.com/';
		$ch=curl_init($url);
		curl_setopt_array($ch, array(
			CURLOPT_POSTFIELDS => $postData,
			CURLOPT_RETURNTRANSFER => true,
			CURLOPT_ENCODING => '',
			CURLOPT_MAXREDIRS => 10,
			CURLOPT_TIMEOUT => 0,
			CURLOPT_FOLLOWLOCATION => true,
			CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
			CURLOPT_CUSTOMREQUEST => 'POST',
			CURLOPT_HTTPHEADER => array(
				'Content-Type: multipart/form-data',
				'Authorization: Bearer '.$this->config['apiKey']
			),
		));
		$response=curl_exec($ch);
		$error=curl_error($ch);
		$httpStatusCode=curl_getinfo($ch, CURLINFO_HTTP_CODE);
		curl_close($ch);
		if(!empty($error)){
			throw new Exception('cURL error: '.$error);
		}
		$this->handleImageSubmissionResponse($response, $httpStatusCode, $queueDirectory);
	}

	private function handleImageSubmissionResponse(string $responseBody, int $httpStatusCode, string $queueDirectory){
		Log::debug("In handleImageSubmissionResponse, HTTP status code is $httpStatusCode, expected code is 202");
		Log::debug('Response body length is '.strlen($responseBody));
		if(202==$httpStatusCode){
			$result=$this->checkJsonAndGetAsArray($responseBody, 'images');
			Log::info('Response contains metadata for '.count($result['images']).' images');
			$count=0;
			foreach ($result['images'] as $image){
				Log::debug('Checking image metadata');
				//TODO Could parse for server job IDs, etc.
				if(!isset($image['clientImageId'])){
					throw new Exception('No clientImageId key on returned image metadata');
				}
				Log::debug('Client image ID is'.$image['clientImageId']);
				$path=rtrim($queueDirectory,'/').'/image_'.$image['clientImageId'].'.json';
				Log::debug("Deleting metadata JSON from submission queue at $path...");
				if(!@unlink($path)){
					throw new Exception("Could not delete image metadata at $path");
				}
				Log::debug('...deleted');
				$count++;
			}
			Log::info("Cleared $count images from submission queue");
		} else if(400==$httpStatusCode){
			$result=$this->checkJsonAndGetAsArray($responseBody, 'errors');
			$this->handleHttp400Response($result, $queueDirectory);
		} else {
			echo $responseBody;
			$this->throwOnBadHttpStatusCode($responseBody, $httpStatusCode);
		}
	}

	private	function handleHttp400Response($result, string $queueDirectory): never {
		Log::debug('In handleHttp400Response');
		Log::info('Response contains '.count($result['errors']).' errors');
		foreach ($result['errors'] as $error){
			Log::error('Error in response: '.$error['message']);
		}
		$this->deleteFromQueueDirectory($queueDirectory, $result['errors']);
		Log::info("Cleared $count rejected images from submission queue");
		throw new Exception('HTTP 400 Bad Request received. See log for details.');
	}

	private static function deleteFromQueueDirectory(string $queueDirectory, array $imagesOrErrors): int {
		Log::debug('In deleteFromQueueDirectory');
		$count=0;
		foreach ($result['images'] as $image) {
			Log::debug('Checking image metadata');
			if (isset($image['clientImageId'])) { continue; }
			Log::debug('Client image ID is' . $image['clientImageId']);
			$path = rtrim($queueDirectory, '/') . '/image_' . $image['clientImageId'] . '.json';
			Log::debug("Deleting metadata JSON from submission queue at $path...");
			if (!@unlink($path)) {
				throw new Exception("Could not delete image metadata at $path");
			}
			Log::debug('...deleted');
			$count++;
		}
		Log::info("Deleted $count items from queue");
		Log::debug('Returning from deleteFromQueueDirectory');
		return $count;
	}

	/**
	 * Checks that the response body is JSON that contains the expected key(s), and returns it as an associative array.
	 * @param string $responseBody The response body, which should be JSON.
	 * @param array|string|null $expectedKeys A single key, or an array of one or more keys, that must be present.
	 * @return array The JSON, decoded into an associative array.
	 * @throws Exception if the response body is not valid JSON or does not contain all expected keys.
	 */
	private function checkJsonAndGetAsArray(string $responseBody, array|string $expectedKeys=null): array {
		$result=json_decode($responseBody,true);
		if(!$result){
			Log::error('Non-JSON response body, or nesting is too deep');
			Log::error(' :::: Begin response body :::: ');
			$lines=explode("\n", $result);
			foreach ($lines as $line){
				Log::error($line);
			}
			Log::error(' :::: End response body :::: ');
			throw new Exception('IceStorm returned a non-JSON response (or its nesting was too deep)');
		}
		if(!is_null($expectedKeys)){
			if(!is_array($expectedKeys)){
				$expectedKeys=[$expectedKeys];
			}
			foreach ($expectedKeys as $expectedKey){
				if(!isset($result[$expectedKey])){
					throw new Exception("Response does not have key $expectedKey, must have ".implode(',',$expectedKeys));
				}
			}
		}
		return $result;
	}

	/**
	 * @param string $responseBody
	 * @param int $httpStatusCode
	 * @return void
	 * @throws Exception
	 */
	private function throwOnBadHttpStatusCode(string $responseBody, int $httpStatusCode): never {
		Log::debug("In throwOnBadHttpStatusCode, HTTP status code is $httpStatusCode");
		Log::error("Unexpected HTTP status code $httpStatusCode received");
		$result=json_decode($responseBody, true);
		if($result){
			if(isset($result['errors'])){
				foreach ($result['errors'] as $error);
				Log::error($error['message']);
			}
		} else {
			Log::error('Response body was not valid JSON, response body follows:');
			Log::error('  ------------------------------------------------------');
			$lines=explode("\n", $responseBody);
			foreach ($lines as $line) {
				Log::error($line);
			}
			Log::error('  ------------------------------------------------------');
		}
		switch($httpStatusCode){
			case 400: //Bad Request
				$exceptionMessage='Received and did not handle HTTP 400 Bad Request.';
			case 401: //Not Authorized
				$exceptionMessage='Received HTTP 401 Not Authorized. Check the API key.';
			case 404: //Not Found
				$exceptionMessage='Received HTTP 404 Not Found. Check the API root and enpoint in config.ini';
			case 403: //Forbidden
				$exceptionMessage='Received HTTP 403 Forbidden. API key is likely valid but does not authorise this action.';
			case 414: //Content Too Large
				$exceptionMessage='Received HTTP 414 Content Too Large. Either the whole POST or a single image was too big.';
			case 429: //Too Many Requests
				$exceptionMessage='Received HTTP 429 Too Many Requests. Consult IceStorm support.';
			default:
				Log::error("Unexpected HTTP status code $httpStatusCode");
				Log::error("Leaving queued image metadata in place for further attempts");
				throw new Exception($exceptionMessage);
		}
	}

	/**
	 * @throws Exception if PHP cURL extension is not available
	 */
	private function checkCurlAvailable(): void {
		Log::debug('In checkCurlAvailable');
		if(!$this->isCurlAvailable()){
			throw new Exception('PHP cURL extension not available');
		}
		Log::debug('returning from checkCurlAvailable');
	}

	/**
	 * @return bool true if PHP cURL extension is available
	 */
	private function isCurlAvailable(): bool {
		Log::debug('returning from checkCurlAvailable');
		$available=function_exists('curl_init');
		Log::debug('PHP cURL extension is '.($available ? 'available': 'not available'));
		Log::debug('returning from checkCurlAvailable');
		return $available;
	}

	/**
	 * @param string $name The name of the lockfile (will be suffixed with .lock)
	 * @return bool true if the lockfile does not exist or is stale, false if it is still fresh
	 * @throws Exception if lockfile's last-modified time cannot be determined
	 * @throws Exception if lockfile cannot be "touch"ed
	 */
	private function writeLockfile(string $name): bool {
		Log::debug('In writeLockfile, $name='.$name);
		$maxLockfileAgeMins=(int)$this->config['maxLockfileAgeMins'];
		Log::debug('Before applying default, $maxLockfileAgeMins='.$maxLockfileAgeMins);
		if(!$maxLockfileAgeMins){ $maxLockfileAgeMins=$this->MAX_LOCKFILE_AGE_DEFAULT; }
		Log::debug('After applying default, $maxLockfileAgeMins='.$maxLockfileAgeMins);
		$filename = $this->getLockfilePath($name);
		if (file_exists($filename)) {
			$now = time();
			$cutoff = $now - (60 * $maxLockfileAgeMins);
			$written = filemtime($filename);
			Log::debug('$now='.$now.', $cutoff='.$cutoff.', $written='.$written);
			if (!$written) {
				throw new Exception('Could not determine last-modified time of lockfile');
			}
			if ($written > $cutoff) {
				//too new, lockfile is in effect
				Log::warn("Lockfile exists at $filename");
				Log::warn("Lockfile is less than $maxLockfileAgeMins minutes old, not writing lockfile");
				return false;
			} else {
				//old enough, pre-existing lockfile can be ignored
				Log::warn("Lockfile exists at $filename");
				Log::warn("Lockfile is more than $maxLockfileAgeMins minutes old, continuing");
			}
		}
		if (!@touch($filename)) {
			throw new Exception('Could not write lockfile');
		}
		Log::debug('Returning from writeLockFile');
		return true;
	}

	/**
	 * Deletes the named file from the lockfiles directory.
	 * @param string $name The name of the lockfile (no extension; will be suffixed with .lock).
	 * @throws Exception if the lockfile cannot be deleted.
	 */
	private function removeLockfile(string $name): void {
		$filename = $this->getLockfilePath($name);
		if (!@unlink($filename)) {
			throw new Exception("Could not remove lockfile at $filename");
		}
	}

	private function getLockfilePath($name): string {
		$directory=$this->config['lockfilesDirectory'];
		if(!$this->canWriteInDirectory($directory)){
			throw new Exception("Cannot write in lockfiles directory $directory");
		}
		return rtrim($directory, '/') . '/IceStorm.class.php_' . $name . '.lock';
	}

	/**
	 * @throws Exception
	 */
	public function getConfig(): array {
		if(isset($this->config)){
			return $this->config;
		}
		$config = @parse_ini_file(__DIR__ . '/../config.ini');
		if (!$config) {
			throw new Exception('Could not read config.ini');
		}
		$requiredFields=[
			'apiKey','logLevel','maxImagesPerRequest','maxMegabytesPerRequest','maxLockfileAgeMinutes',
			'maxSubmissionRunTimeSeconds','lockfilesDirectory','humanScoredImagesDirectory','unscoredImagesDirectory',
			'apiRoot','unscoredImagesEndpoint','humanScoredImagesEndpoint','scoresRetrievalEndpoint'
		];
		foreach ($requiredFields as $field) {
			if(!isset($config[$field])){
				throw new Exception("Missing config entry $field in IceStorm config.ini");
			}
		}
		return $config;
	}

	/**
	 * Checks whether the directory can be written to, creating it if it does not exist.
	 * @param string $directory The directory to be checked.
	 * @return bool true if the directory can be written to, otherwise false.
	 * @throws Exception if $directory does not exist and cannot be created.
	 */
	private function canWriteInDirectory(string $directory): bool {
		if(!file_exists($directory) && !@mkdir($directory, 0777, true)){
			throw new Exception("Directory does not exist and cannot be created: $directory");
		}
		$testFile=rtrim($directory, '/').'/testWrite';
		if(!@touch($testFile)){ return false; }
		@unlink($testFile);
		return true;
	}

	/**
	 * @throws Exception
	 */
	private function logAndRethrowException($e): void {
		if(!Log::isInited()){ throw $e; }
		$trace = $e->getTrace();
		$out = [];
		$out[] = $e->getMessage();
		$out[] = $e->getFile() . ' line ' . $e->getLine();
		foreach ($trace as $t) {
			$line = (isset($t['line'])) ? $t['line'] : '--';
			$file = (isset($t['file'])) ? str_replace('\\', '/', $t['file']) : '--';
			if (array_key_exists('class', $t)) {
				$out[] = sprintf("%s:%s %s::%s",
					$file,
					$line,
					$t['class'],
					$t['function']
				);
			} else {
				$out[] = sprintf("%s:%s %s",
					$file,
					$line,
					$t['function']
				);
			}
		}
		foreach ($out as $line) {
			Log::error($line);
		}
		throw $e;
	}

}

class Log {

	const LOGLEVEL_DEBUG=1;
	const LOGLEVEL_INFO=2;
	const LOGLEVEL_WARN=3;
	const LOGLEVEL_ERROR=4;

	const LOG_LABELS=array(
		Log::LOGLEVEL_DEBUG=>'DEBUG',
		Log::LOGLEVEL_INFO =>'INFO ',
		Log::LOGLEVEL_WARN =>'WARN ',
		Log::LOGLEVEL_ERROR=>'ERROR',
	);

	private static int $logLevel;
	private static mixed $logFileHandle;
	private static bool $inited=false;

	public static function getLogLevels(): array {
		return [
			[ 'name'=>'Debug', 'color'=>'999', 'severity'=>self::LOGLEVEL_DEBUG ],
			[ 'name'=>'Info', 'color'=>'090', 'severity'=>self::LOGLEVEL_INFO ],
			[ 'name'=>'Warning', 'color'=>'990', 'severity'=>self::LOGLEVEL_WARN ],
			[ 'name'=>'Error', 'color'=>'900', 'severity'=>self::LOGLEVEL_ERROR ]
		];
	}

	/**
	 * Initiates logging. DOES NOT handle log rotation; must be handled by caller or otherwise.
	 * @param string|null $logFilePath The full path of the file to append to (creating if needed), or empty to echo logs to output.
	 * @param int $logLevel One of the constants Log::LOGLEVEL_DEBUG, Log::LOGLEVEL_INFO, Log::LOGLEVEL_WARN, Log::LOGLEVEL_ERROR.
	 * @throws Exception
	 */
	public static function init(int $logLevel, string $logFilePath=null): void {
		if(empty($logFilePath)) {
			static::$logFileHandle=null;
		} else {
			static::$logFileHandle=@fopen($logFilePath,'a');
			if(!static::$logFileHandle){
				throw new Exception('Could not open log file '.$logFilePath.' for appending.');
			}
		}
		static::setLogLevel($logLevel);
		static::$inited=true;
	}

	public static function getLogLevel(): int {
		return static::$logLevel;
	}

	public static function setLogLevel(int $logLevel): int {
		static::$logLevel=$logLevel;
		return static::$logLevel;
	}

	public static function isInited(): bool {
		return static::$inited;
	}

	/**
	 * Writes the supplied message to the log, with level and GMT date/time, if the log level is equal to or higher than that specified in init().
	 * @param int $logLevel The log level for this message.
	 * @param string $message The message to log
	 * @throws Exception
	 */
	public static function write(int $logLevel, string $message): void {
		if(!static::$inited){ throw new Exception('Log not init()ed.'); }
		if($logLevel<static::$logLevel){ return; }
		$labels=static::LOG_LABELS;
		$out=$labels[$logLevel].' '.gmdate('d M y H:i:s').' '.$message."\n";
		if(static::$logFileHandle){
			fwrite(static::$logFileHandle, $out);
			fflush(static::$logFileHandle);
		} else {
			echo $out;
		}
	}

	/**
	 * @param string $message
	 * @throws Exception
	 */
	public static function debug(string $message): void {
		Log::write(Log::LOGLEVEL_DEBUG, $message);
	}

	/**
	 * @param string $message
	 * @throws Exception
	 */
	public static function info(string $message): void {
		Log::write(Log::LOGLEVEL_INFO, $message);
	}

	/**
	 * @param string $message
	 * @throws Exception
	 */
	public static function warning(string $message): void {
		Log::write(Log::LOGLEVEL_WARN, $message);
	}

	/**
	 * @param string $message
	 * @throws Exception
	 */
	public static function warn(string $message): void {
		Log::write(Log::LOGLEVEL_WARN, $message);
	}

	/**
	 * @param $message
	 * @throws Exception
	 */
	public static function error($message): void {
		Log::write(Log::LOGLEVEL_ERROR, $message);
	}


	/**
	 * Tidies up, closing the log file handle.
	 */
	public static function end(): void {
		static::$inited=false;
		if(static::$logFileHandle){
			fflush(static::$logFileHandle);
			@fclose(static::$logFileHandle);
			static::$logFileHandle=null;
		}
	}


}

