<?php

const ICEBEAR_DEVICE_SCOPE = 'RigakuImport';
const LOCKFILE=__DIR__.'/lockfile';
const DISK_MINIMUM_GIGABYTES = 50;
const ERROR_HANDLING_STOP='stop';
const ERROR_HANDLING_WARN='warn';
const ERROR_HANDLING_IGNORE='ignore';
const PLATEXML_PLATE_EXISTS_IN_ICEBEAR=1;
const PLATEXML_PLATE_CREATED_IN_ICEBEAR=2;
const PLATEXML_NOT_PROCESSED=0;
const OPTIMIZATION_SCREEN_NAMES=['custom'];

try {
    $configFileName='config.json';
    $config=getConfig($configFileName);
    $logFilename=null;
    if(isset($config['logging']['logFile'])){
        $logFilename=$config['logging']['logFile'];
    }
    date_default_timezone_set('UTC');
    Logger::init($config['logging']['logLevel'], $logFilename);
    Logger::info('=============================');
    Logger::info('Beginning image push');

    if(file_exists(LOCKFILE)){
        Logger::warning('Lockfile from previous run exists.');
        $now=time();
        $lastModified=filemtime(LOCKFILE);
        if(!$lastModified){
            Logger::warning('Cannot determine age of lockfile. Exiting.');
            exit();
        } else if($now-(60*$config['lockfileMaximumAgeMinutes'])<$lastModified){
            Logger::warning('Lockfile is too new, age '.floor(($now-$lastModified)/60).' minutes (can delete when '.$config['lockfileMaximumAgeMinutes'].' minutes old). Exiting.');
            exit();
        } else {
            Logger::warning('Lockfile is older than '.$config['lockfileMaximumAgeMinutes'].'. Deleting...');
            @unlink(LOCKFILE);
            if(file_exists(LOCKFILE)){
                throw new Exception('Unable to delete lockfile.');
            }
            Logger::warning('Lockfile deleted.');
        }
    }
    if(!@touch(LOCKFILE)){
        throw new Exception('Could not write lockfile at '.LOCKFILE);
    }

    $config=getConfig($configFileName);
    validateConfig($config);
    if(isset($config['logLevel'])){
        Logger::setLogLevel($config['logLevel']);
    }
    checkIceBearAvailable($config);
    getReimportBarcode($config);
    doPush($config);
    updateDiskSpace($config);
    Logger::info('Image push finished successfully');
} catch (Exception $e ){
    try {
        Logger::error($e->getMessage());
        Logger::info('Image push finished with errors');
    } catch (Exception $e) {}
} finally {
    Logger::end();
    @unlink(LOCKFILE);
}
/**
 * @return mixed
 * @throws Exception
 */
function getConfig($configFileName){
    $config=@file_get_contents(__DIR__."/$configFileName");
    if(!$config){ throw new Exception("Could not open ".__DIR__."/$configFileName"); }
    $config=json_decode($config,true);
    if(!$config){ throw new Exception('Config file at '.__DIR__."/$configFileName is not valid JSON"); }
    return $config;
}

/**
 * @param $config
 * @return void
 * @throws Exception
 */
function validateConfig(&$config){
    Logger::debug('In validateConfig');
    if(!function_exists('curl_init')){
        throw new Exception('Cannot communicate with IceBear because PHP cURL extension is not installed');
    }

    if(!is_array($config)){
        throw new Exception('Config could not be parsed into an array');
    }

    if(!isset($config['imagesPerPush'])){
        Logger::warning('Config imagesPerPush is not set. Defaulting to 10.');
        $config['imagesPerPush']=10;
    } else if(!is_int($config['imagesPerPush']) || $config['imagesPerPush']<1){
        throw new Exception('Config imagesPerPush must be an integer greater than zero');
    }

    if(isset($config['logLevel'])){
        $logLevel=1*$config['logLevel'];
        if(!is_int($logLevel) || $logLevel<Logger::LOGLEVEL_DEBUG || $logLevel>Logger::LOGLEVEL_ERROR){
            throw new Exception('Log level should be an integer from '.Logger::LOGLEVEL_DEBUG.' to '.Logger::LOGLEVEL_ERROR);
        }
    }

    if(isset($config['importFromDate'])){
        if(!preg_match('|^\d\d\d\d-\d\d-\d\d$|', $config['importFromDate'])){
            throw new Exception('Bad import-from date in config. Should be in YYYY-MM-DD format.');
        }
    }

    if(isset($config['updateDiskSpace'])){
        $config['updateDiskSpace']=in_array($config['updateDiskSpace'], [1,'1',true,'true','yes']);
    } else {
        $config['updateDiskSpace']=true;
    }

    if(!isset($config['iceBear'])){ throw new Exception('No IceBear instance defined in config'); }
    $iceBear=$config['iceBear'];
    $required=['url','apiKey'];
    foreach ($required as $key){
        if(!isset($iceBear[$key]) || ''==trim($iceBear[$key])){
            Logger::error('IceBear config error');
            Logger::error('Required keys: '.implode(',',$required));
            Logger::error('Present: '.implode(',',array_keys($iceBear)));
            if(isset($iceBear[$key])){
                Logger::error("$key is empty");
            }
            throw new Exception('Missing keys/values in IceBear config');
        }
    }

    if(!$config['imagers']){ throw new Exception('No imagers defined in config'); }
    $imagers=$config['imagers'];
    if(!is_array($imagers)){
        Logger::warning('Expecting an array of imagers, got an object');
        Logger::warning('Assuming one imager, continuing');
        $imagers=array($imagers);
    }
    foreach ($imagers as $imager){
        $required=['name','nominalTemperature','plateCapacity','platesDirectory','imagesDirectory','thumbnailsDirectory'];
        $paths=['platesDirectory','imagesDirectory','thumbnailsDirectory'];
        foreach ($required as $key){
            if(!isset($imager[$key]) || ''==trim($imager[$key])){
                Logger::error('Imager config error');
                Logger::error('Required keys: '.implode(',',$required));
                Logger::error('Present: '.implode(',',array_keys($imager)));
                if(isset($imager[$key])){
                    Logger::error("$key is empty");
                }
                throw new Exception('Missing keys/values in imager config');
            }
        }
        if(isset($imager['micronsPerPixel']) && (!is_numeric($imager['micronsPerPixel']) ||  0>=1*$imager['micronsPerPixel'] ) ){
            throw new Exception('Imager micronsPerPixel must be a number greater than zero, or omitted if unknown');
        }
        if(!is_int(1*$imager['nominalTemperature'])){
            throw new Exception('Imager nominalTemperature must be an integer');
        }
        if(!is_int(1*$imager['plateCapacity']) || 0>$imager['plateCapacity']){
            throw new Exception('Imager plateCapacity must be an integer above zero');
        }
        foreach ($paths as $path) {
            if(!file_exists($imager[$path])){
                throw new Exception('Path $path does not exist');
            }
        }
    }

    if(!isset($config['errorHandling'])){
        Logger::warning('No errorHandling block in config. The importer will stop for any errors where behaviour could be defined.');
    } else {
        Logger::debug('config errorHandling block exists');
        $validValues=[ ERROR_HANDLING_STOP, ERROR_HANDLING_WARN, ERROR_HANDLING_IGNORE ];
        foreach($config['errorHandling'] as $k=>$v){
            Logger::debug("Config errorHandling: $k=$v");
            if(!in_array($v, $validValues)){
                Logger::error("Config errorHandling: $k has invalid value $v");
                Logger::error('Should be one of "'.implode('","', $validValues).'"');
                throw new Exception("Invalid configuration");
            }
        }
    }

    Logger::debug('Returning from validateConfig');
}

/**
 * @param $config
 * @return void
 * @throws Exception
 */
function checkIceBearAvailable($config){
    Logger::debug('In checkIceBearAvailable');
    $response=iceBearRequest('checkIceBearAvailable',[],$config);
    if(empty($response)){
        Logger::error('Got an empty response from cURL when checking IceBear');
        Logger::error('Verify that IceBear is available and is visible from the machine running this push code');
        throw new Exception('Empty response; is IceBear up and visible from this machine?');
    }
    Logger::debug('IceBear is available and visible');
    Logger::debug('Returning from checkIceBearAvailable');
}

/**
 * @param $config
 * @throws Exception
 */
function getReimportBarcode(&$config){
    $barcode=null;
    Logger::debug('In getReimportBarcode');
    $response=iceBearRequest('getPlateForReimport',[],$config);
    if(!empty($response) && !empty($response['barcode'])){
        $barcode=$response['barcode'];
        Logger::info("Plate $barcode is queued for reimport");
    } else {
        Logger::info("No plates were queued for reimport");
    }
    $config['reimportBarcode']=$barcode;
    Logger::debug('Returning from getReimportBarcode');
}

/**
 * @param $config
 * @return void
 * @throws Exception
 */
function doPush(&$config){
    Logger::debug('In doPush');
    getScoringSystemId($config);
    getImagingProfiles($config);
    $imagers=$config['imagers'];
    foreach($imagers as $imager){
        pushFromImager($imager, $config);
    }
    Logger::debug('Returning from doPush');
}

/**
 * @param $imager
 * @param $config
 * @return void
 * @throws Exception
 */
function pushFromImager($imager, &$config){
    Logger::debug('In pushFromImager');
    foreach($imager as $k=>$v){
        Logger::debug("$k=$v");
    }
    $imagerName=$imager['name'];
    Logger::info('Beginning push from imager '.$imagerName);
    $iceBearImager=getIceBearImager($imager, $config);
    $imagerId=$iceBearImager['id'];
    Logger::debug("IceBear imager ID is $imagerId");
    $scoringSystemId=$config['scoringSystem']['id'];
    Logger::debug("Scoring system ID is $scoringSystemId");

    //Clear cached users/plate types from previous imagers
    $config['plateTypes']=[];
    $config['users']=[];

    $plateXmlFilenames=getPlateXmlFilesForImager($imager);
    if(empty($plateXmlFilenames)){
        Logger::info('No plate XML files found');
    } else {
        $plateXmlFilesProcessed=[];
        $plateXmlFilesNotProcessed=[];
        Logger::info(count($plateXmlFilenames).' plate XML files found');
        foreach ($plateXmlFilenames as $filename){
            $result=processPlateXml($filename, $imager, $config);
            if(PLATEXML_PLATE_CREATED_IN_ICEBEAR==$result){
                $plateXmlFilesProcessed[]=$filename;
                Logger::info("Plate created from $filename");
            } else if(PLATEXML_PLATE_EXISTS_IN_ICEBEAR==$result){
                $plateXmlFilesProcessed[]=$filename;
                Logger::info("Plate in $filename already exists in IceBear");
                if($config['platesStopOnFirstExisting']){
                    Logger::info("Not processing earlier plate XML files (config platesStopOnFirstExisting is true)");
                    break;
                } else {
                    Logger::debug("Continuing earlier plate XML files (config platesStopOnFirstExisting is false)");
                }
            } else if(PLATEXML_NOT_PROCESSED==$result){
                $plateXmlFilesNotProcessed[]=$filename;
                Logger::warning("$filename was not processed - does not look like plate XML");
            }
        }
        Logger::info(count($plateXmlFilesProcessed).' plate XML files processed');
        if(!empty($plateXmlFilesNotProcessed)){
            Logger::warning(count($plateXmlFilesNotProcessed).' XML files not processed - do not look like plate XML:');
            foreach ($plateXmlFilesNotProcessed as $filename){
                Logger::warning(" * $filename");
            }
        }
    }

    //Now do the images. Image directories by last modified time desc, then inspections by last modified time desc
    $barcodesAndInspections=getInspectionDirectories($imager,$config);
    createInspections($barcodesAndInspections, $imager, $config);

    Logger::info('Finished push from imager '.$imagerName);
    Logger::debug('Returning from pushFromImager');
}

/**
 * Creates a scoring system. If none is present in $config, the default CrystalTrak scores will be created by IceBear.
 * @param $config
 * @return void
 * @throws Exception
 */
function getScoringSystemId(&$config){
    Logger::debug('In createScoringSystem');
    $scoringSystem=null;
    if(isset($config['scoringSystem'])){
        $scoringSystem=$config['scoringSystem'];
    }
    $scoringSystem=iceBearRequest('createScores',$scoringSystem,$config);
    $config['scoringSystem']=$scoringSystem;
    Logger::debug('Returning from createScoringSystem');
}

/**
 * @param $config
 * @return void
 * @throws Exception
 */
function getImagingProfiles(&$config){
    $result=iceBearRequest('getImagingProfiles',['profiles'=>$config['imagingTypes']], $config);
    $config['imagingTypes']=$result['profiles'];
}

/**
 * @param $imager
 * @param $config
 * @return array
 * @throws Exception
 */
function getIceBearImager($imager,$config){
    Logger::debug('In getIceBearImager');
    $imager=iceBearRequest('createImager',[
        'name'=>$imager['name'],
        'plateCapacity'=>$imager['plateCapacity'],
        'nominalTemperature'=>$imager['nominalTemperature']
    ],$config);
    Logger::debug('Returning from getIceBearImager');
    return $imager;
}

/**
 * @throws Exception
 */
function getPlateXmlFilesForImager($imager){
    Logger::debug('In getPlateXmlFilesForImager');
    $xmlFiles=getDirectoryListingSortedLastModifiedDescending($imager['platesDirectory'],'/.xml$/');
    Logger::info('Found '.count($xmlFiles).' plate XML files');
    Logger::debug('Returning from getPlateXmlFilesForImager');
    return $xmlFiles;
}

/**
 * @param $filename
 * @param $imager
 * @param $config
 * @return int 0 if XML not processed, 1 if plate already exists, 2 if plate was created.
 * @throws Exception
 */
function processPlateXml($filename, $imager, &$config){
    Logger::debug('In processPlateXml, $filename='.$filename);
    Logger::info("Processing plate XML file $filename");
    $plateStore=rtrim($imager['platesDirectory'],'/').'/';
    $xml=@simplexml_load_file($plateStore.$filename);
    if(false===$xml){
        throw new Exception('Could not parse plate XML '.$filename);
    }

    $dataType=(string)$xml['datatype'];
    if(""==$dataType){
        handleErrorCondition('plateXmlNotAPlate','No datatype attribute on XML root element. Not CrystalTrak plate XML', $config);
        return PLATEXML_NOT_PROCESSED;
    } else if("plate"!==$dataType){
        handleErrorCondition('plateXmlNotAPlate','Datatype attribute on XML root element is not "plate". Not CrystalTrak plate XML', $config);
        return PLATEXML_NOT_PROCESSED;
    }

    $xmlPlate=$xml->plate->attributes();
    $plateBarcode=(string)$xmlPlate['barcode'];
    $plateDescription=(string)$xmlPlate['name'];
    Logger::info("Plate barcode is $plateBarcode");

    //Get existing plate
    if(!isset($config['plates'])){ $config['plates']=[]; }
    $existingPlate=getExistingIceBearPlate($plateBarcode, $config);
    if($existingPlate){
        $config['plates'][$plateBarcode]=$existingPlate;
        Logger::info('Plate exists in IceBear');
        Logger::debug('Returning from processPlateXml');
        return PLATEXML_PLATE_EXISTS_IN_ICEBEAR;
    } else {
        Logger::info('Plate does not exist in IceBear');
    }

    //Verify that the plate and imager temperature match
    $imagerTemperature=1*$imager['nominalTemperature'];
    $plateTemperature=(string)$xmlPlate['temperature'];
    if(empty($plateTemperature)){
        Logger::debug("Plate temperature not set, assuming imager temperature $imagerTemperature");
    } else {
        Logger::debug("Plate temperature $plateTemperature, imager temperature $imagerTemperature");
        $plateTemperature=1*$plateTemperature;
        if($plateTemperature!==$imagerTemperature){
            handleErrorCondition('imagerPlateTemperatureMismatch','Plate and imager temperature do not match', $config);
        }
    }


    //Get or create the user (plate owner)
    if(!isset($config['users'])){ $config['users']=[]; }
    $username=(string)$xmlPlate['user'];
    Logger::info("Plate owner is $username");
    if(isset($config['users'][$username]['id'])){
        Logger::debug('User is already in cache');
        $owner=$config['users'][$username];
    } else {
        $user=[ 'username'=>$username ];
        Logger::debug('User not in cache, getting/creating from IceBear');
        $owner=getIceBearUser($user, $config);
        $config['users'][$username]=$owner;
    }
    Logger::info('Plate owner is: '.$owner['name'].' (id: '.$owner['id'].')');

    //Get project ID (needed for plate, and for screen, unless promoted to standard)
    $projectName=(string)$xmlPlate['project'];
    $iceBearProject=getIceBearProject($projectName, $owner['id'], $config);
    $projectId=$iceBearProject['id'];
    Logger::debug('Returning from getIceBearProject');

    //Get or create the plate type
    if(!isset($config['plateTypes'])){ $config['plateTypes']=[]; }
    $xmlPlateType=$xml->plate->format->attributes();
    $plateType=[];
    foreach(['name','rows','cols','subs'] as $key){
        $plateType[$key]=(string)$xmlPlateType[$key];
    }
    $plateTypeName=$plateType['name'];
    Logger::info("Plate type is $plateTypeName");
    if(isset($config['plateTypes'][$plateTypeName]['id'])){
        Logger::debug('Plate is already in cache');
        $plateTypeId=$config['plateTypes'][$plateTypeName]['id'];
    } else {
        Logger::debug('Plate type not in cache, getting/creating from IceBear');
        $plateType=getIceBearPlateType($plateType, $config);
        $config['plateTypes'][$plateTypeName]=$plateType;
        $plateTypeId=$plateType['id'];
    }
    Logger::debug("Plate type ID is $plateTypeId");

    //Get or create the screen
    if(!isset($config['screens'])){ $config['screens']=[]; }
    $xmlScreen=$xml->screen;
    $screenName=(string)$xmlScreen['name'];
    $screenNameIsOptimization=in_array($screenName, OPTIMIZATION_SCREEN_NAMES);
    $numWells=0;
    $xmlScreenFormat=$xmlScreen->format;
    if($xmlScreenFormat){
        $screenRows=(int)$xmlScreenFormat['rows'];
        $screenCols=(int)$xmlScreenFormat['cols'];
        Logger::debug('Plate '.$plateType['rows'].'x'.$plateType['cols'].', screen '.$screenRows.'x'.$screenCols);
        foreach ($xmlScreen->children() as $well) {
            if("well"===$well->getName()){ $numWells++; }
        }
    }
    if(!$xmlScreenFormat) {
        $attachScreen = false;
        Logger::warning("Screen $screenName has no format. Not attaching screen to plate.");
    } else if(!$numWells) {
        $attachScreen = false;
        Logger::warning("Screen $screenName has no conditions. Not attaching screen to plate.");
    } else if($screenRows!=$plateType['rows'] || $screenCols!=$plateType['cols']){
        $attachScreen = false;
        Logger::warning('Plate '.$plateType['rows'].'x'.$plateType['cols'].', screen '.$screenRows.'x'.$screenCols);
        Logger::warning("Screen and plate size mismatch. Not attaching screen to plate.");
    } else {
        $attachScreen=true;
        $conditions=[];
        foreach ($xmlScreen->children() as $well) {
            if("well"!=$well->getName()){ continue; } //Not the "format" or "comments" elements
            $components=[];
            $parts=$well->children();
            foreach ($parts as $item){
                $component=[
                    'concentration'=>1*(string)$item['conc'],
                    'name'=>(string)$item['name'],
                    'unit'=>(string)$item['units'],
                    'role'=>(string)$item['class']
                ];
                $description=$component['concentration'].$component['unit'].' '.$component['name'];
                $pH=(string)$item['ph'];
                if(''!==$pH){
                    $component['pH']=1*$pH;
                    $description.=', pH '.$component['pH'];
                }
                $descriptionNoRoles=$description;
                $description=$component['role'].': '.$description;
                $component['description']=$description;
                $component['descriptionNoRoles']=$descriptionNoRoles;
                $components[]=$component;
            }
            $conditions[]=[
                'wellNumber'=>(int)$well['number'],
                'components'=>$components,
                'description'=>trim(implode('; ', array_column($components, 'description'))),
                'descriptionNoRoles'=>trim(implode('; ', array_column($components, 'descriptionNoRoles')))
            ];
        }
        $xmlScreenText=implode('|',array_column($conditions,'description'));
        $xmlScreenTextNoRoles=implode('|',array_column($conditions,'descriptionNoRoles'));

        $existingScreen=null;
        if($screenNameIsOptimization) {
            Logger::info("Screen name is '$screenName' - treating as optimization screen");
        } else {
            $existingScreen=getExistingIceBearScreen($screenName, $config);
        }
        if($existingScreen){
            Logger::debug('Screen exists in IceBear');
            if(isset($config['screens'][$screenName])){
                $existingScreenText=implode('|',array_column($existingScreen['conditions'], 'description'));
                //Screens with fewer than 96 conditions will be padded with "Unknown". Need to trim these or comparison will fail.
                $existingScreenText=implode('',explode('|Unknown', $existingScreenText));
                if($existingScreenText!==$xmlScreenText && $existingScreenText!==$xmlScreenTextNoRoles){
                    Logger::warning('Screen conditions mismatch');
                    Logger::debug(' ');
                    Logger::debug('Existing IceBear screen: '.$screenName);
                    Logger::debug('XML screen');
                    Logger::debug(' ');
                    $chunkSize=160;
                    $start=0;
                    while($start<strlen($existingScreenText)){
                        $old=(substr($existingScreenText, $start, $chunkSize));
                        $new=(substr($xmlScreenTextNoRoles, $start, $chunkSize));
                        Logger::debug($old);
                        Logger::debug($new);
                        if($old==$new){
                            Logger::debug(' ');
                        } else {
                            Logger::debug('^^^^^^^^^^^ different above');
                        }
                        $start+=$chunkSize;
                    }
                    handleErrorCondition('xmlIceBearScreenConditionsMismatch', 'Screen conditions in XML do not match those in existing IceBear screen with same name', $config);
                } else {
                    Logger::debug('Verified that screen conditions match');
                }
            }
            $isStandard=(int)($existingScreen['isstandard']);
            if(!$isStandard && $projectId!==$existingScreen['projectid']){
                Logger::info('Existing screen has different project to the plate');
                Logger::info('Promoting the screen to a standard screen');
                promoteScreenToStandard($existingScreen['id'], $config);
            }
            $config['screens'][$screenName]=$existingScreen;
        } else {
            Logger::debug('Screen does not exist in IceBear');
            if($screenNameIsOptimization){
                Logger::info("Screen name is '$screenName' - changing to 'Optimization $plateBarcode'");
                $screenName='Optimization '.$plateBarcode;
            }
            $newScreen=[
                'name'=>$screenName,
                'rows'=>$screenRows,
                'cols'=>$screenCols,
                'conditions'=>$conditions
            ];
            try {
                $config['screens'][$screenName]=createIceBearScreen($newScreen, $config);
            } catch (Exception $e){
                if(false===stripos($e->getMessage(), 'no conditions')){
                    Logger::warning('No conditions received at IceBear');
                    Logger::warning('Screen not created');
                } else {
                    throw $e;
                }
            }

        }
        Logger::debug('Screen ID is '.$config['screens'][$screenName]['id']);
    }

    Logger::debug('Creating plate');
    $plate=[
        'barcode'=>$plateBarcode,
        'description'=>$plateDescription,
        'projectId'=>$projectId,
        'plateType'=>$plateType,
        'owner'=>$owner
    ];
    if($attachScreen){
        $plate['screenId']=$config['screens'][$screenName]['id'];
    }
    $plate=createIceBearPlate($plate, $config);
    Logger::info("Plate $plateBarcode created");
    $config['plates'][$plateBarcode]=$plate;

    Logger::debug('Returning from processPlateXml');
    return PLATEXML_PLATE_CREATED_IN_ICEBEAR;
}

/**
 * @param $projectName
 * @param $ownerId
 * @param $config
 * @return array
 * @throws Exception
 */
function getIceBearProject($projectName, $ownerId, $config){
    Logger::debug('In getIceBearProject');
    $project=iceBearRequest('getProject',['name'=>$projectName], $config);
    if(!$project){
        $project=iceBearRequest('createProject',[
            'name'=>$projectName,
            'ownerId'=>$ownerId,
            'description'=>$projectName,
            'createProtein'=>true
        ], $config);
    }
    if(!$project){
        throw new Exception("Project $projectName does not exist in IceBear and could not be created");
    }
    Logger::debug('Returning from getIceBearProject');
    return $project;
}

/**
 * @param $xmlPlateType
 * @param $config
 * @return array
 * @throws Exception
 */
function getIceBearPlateType($xmlPlateType, $config){
    Logger::debug('In getIceBearPlateType');
    $plateType=iceBearRequest('getPlateType', [
        'name'=>(string)$xmlPlateType['name'],
        'rows'=>(string)$xmlPlateType['rows'],
        'columns'=>(string)$xmlPlateType['cols'],
        'subpositions'=>(string)$xmlPlateType['subs'],
    ], $config);
    Logger::debug('Returning from getIceBearPlateType');
    return $plateType;
}

/**
 * @param $xmlUser
 * @param $config
 * @return array
 * @throws Exception
 */
function getIceBearUser($xmlUser, $config){
    Logger::debug('In getIceBearUser');
    $user=iceBearRequest('getUser',[
        'username'=>$xmlUser['username']
    ], $config);
    Logger::debug('Returning from getIceBearUser');
    return $user;
}

/**
 * @param $screenName
 * @param $config
 * @return array
 * @throws Exception
 */
function getExistingIceBearScreen($screenName, $config){
    Logger::debug('In getIceBearScreen');
    $screen=iceBearRequest('getScreenByName',[
        'name'=>$screenName
    ], $config);
    Logger::debug('Returning from getIceBearScreen');
    return $screen;
}

/**
 * @param $screen
 * @param $config
 * @return array
 * @throws Exception
 */
function createIceBearScreen($screen, $config){
    Logger::debug('In createIceBearScreen');
    $screen=iceBearRequest('createScreen', $screen, $config);
    Logger::debug('Returning from createIceBearScreen');
    return $screen;
}

/**
 * @param $screenId
 * @param $config
 * @return array
 * @throws Exception
 */
function promoteScreenToStandard($screenId, $config){
    return iceBearRequest('promoteScreenToStandard',['screenId'=>$screenId],$config);
}

/**
 * @param $plateBarcode
 * @param $config
 * @return array
 * @throws Exception
 */
function getExistingIceBearPlate($plateBarcode, $config){
    Logger::debug("In getExistingIceBearPlate, barcode=$plateBarcode");
    $plate=iceBearRequest('getPlateByBarcode',[
        'barcode'=>$plateBarcode
    ], $config);
    Logger::debug("Returning from getExistingIceBearPlate");
    return $plate;
}

/**
 * @throws Exception
 */
function createIceBearPlate($plate, $config){
    return iceBearRequest('createPlate', $plate, $config);
}

/**
 * @param $imager
 * @param $config
 * @return array
 * @throws Exception
 */
function getInspectionDirectories($imager, $config){
    Logger::debug('In getInspectionDirectories');
    $imagesDirectory=rtrim($imager['imagesDirectory'],'/').'/';
    $plateDirectories=getDirectoryListingSortedLastModifiedDescending($imagesDirectory,'/^[A-Z]{2}[0-9]{6}$/');
    if(!empty($config['reimportBarcode'])){
        //Prioritise reimport - clearly, someone's interested.
        array_unshift($plateDirectories, $config['reimportBarcode']);
    }
    $tooNewCutoff=time()-(60*$config['inspectionMinimumAgeMinutes']);
    $importFrom=DateTime::createFromFormat('Y-m-d', $config['importFromDate'])->getTimestamp();
    $ret=[];
    $plateCount=0;
    $inspectionCount=0;
    foreach($plateDirectories as $barcode){
        if($barcode===$config['reimportBarcode']){
            if(file_exists($imagesDirectory.$barcode)){
                Logger::info("Plate $barcode, queued for reimport, found on this imager");
                $config['reimportBarcode']=null; //no need to check other imagers
            } else {
                Logger::info("Plate $barcode, queued for reimport, not found on this imager");
                continue;
            }
        }
        if(filemtime($imagesDirectory.$barcode)<$importFrom){
            Logger::debug("Images directory for $barcode not modified since import-from date, breaking out");
            break;
        }
        $inspections=getDirectoryListingSortedLastModifiedDescending($imagesDirectory.$barcode,'/^[0-9]+$/');
        $inspectionsToImport=[];
        foreach ($inspections as $inspection){
            if(filemtime($imagesDirectory.$barcode.'/'.$inspection)>$tooNewCutoff){
                Logger::debug("Images directory for $barcode inspection $inspection is too new, not adding to list");
                continue;
            }
            $inspectionsToImport[]=$inspection;
            $inspectionCount++;
        }
        if(!empty($inspectionsToImport)){
            $ret[$barcode]=$inspectionsToImport;
            $plateCount++;
        }
    }
    Logger::info("Found $inspectionCount inspections in $plateCount plate directories");
    Logger::debug('Returning from getInspectionDirectories');
    return $ret;
}

/**
 * @param $barcodesAndInspections
 * @param $imager
 * @param $config
 * @return void
 * @throws Exception
 */
function createInspections($barcodesAndInspections, $imager, $config){
    Logger::debug('In importInspections');
    $completedInspectionsFound=0;
    $stopAfterCompletedInspectionsFound=max(0,1*$config['inspectionsStopAfterThisManyFoundCompleted']);
    $completedLimitReached=false;
    foreach($barcodesAndInspections as $barcode=>$inspections) {
        //Create the imagingsession, if it doesn't exist
        $iceBearInspections=iceBearRequest('getInspectionsForPlate',['barcode'=>$barcode], $config);
        if(!$iceBearInspections){ $iceBearInspections=[]; }
        $plateInspectionsDirectory=rtrim($imager['imagesDirectory'],'/').'/'.$barcode.'/';
        foreach ($inspections as $inspection){
            Logger::debug("Checking images directory for $barcode inspection $inspection");
            $inspectionDirectory=$plateInspectionsDirectory.$inspection;
            $allImages=getDirectoryListingSortedLastModifiedDescending($inspectionDirectory, '|'.$barcode.'_\d{3}_\d{6}_\d{2}_\d{2}_\d{2}_[A-Za-z0-9]{2}|');
            if(!$allImages){
                //ASSUME all inspections have images. Lack of at least one will cause inspection to be "too new" and skipped.
                Logger::warning("$barcode inspection $inspection has no images. This may be because it is too new.");
                continue;
            } else {
                $imagedTime=filemtime($inspectionDirectory.'/'.$allImages[0]);
            }
            if($imagedTime>time()-(60*$config['inspectionMinimumAgeMinutes'])){
                Logger::info("$barcode inspection $inspection is too new; not importing on this run");
                continue;
            }
            foreach ($config['imagingTypes'] as $key=>$imagingType) {

                //Apply an offset of a few seconds to each imaging type, based on its order in $config['imagingTypes'].
                //Visible should be listed FIRST and have zero offset. Any other imaging types will have x seconds
                //SUBTRACTED from the imaging time, where x is the type's index in array_keys($config['imagingTypes']).
                $imagedTime=filemtime($inspectionDirectory.'/'.$allImages[0]);
                $imagingTypeSecondsOffset=array_search($key, array_keys($config['imagingTypes']));
                Logger::debug("Offsetting images for type '$key' by $imagingTypeSecondsOffset seconds");
                $imagedTime-=$imagingTypeSecondsOffset;
                $imagedTimeFormatted=gmdate('Y-m-d H:i:s', $imagedTime);

                //Get the images; Composites if available, otherwise best-slice
                $sliceNumber='00'; //Composite or "extended-focus" image
                $images=getDirectoryListingSortedLastModifiedDescending($inspectionDirectory, '|'.$barcode.'_\d{3}_\d{6}_\d{2}_\d{2}_\d{2}_'.$key.'_'.$sliceNumber.'|');
                if(!$images){
                    $sliceNumber='99'; //Best-slice image
                    $images=getDirectoryListingSortedLastModifiedDescending($inspectionDirectory, '|'.$barcode.'_\d{3}_\d{6}_\d{2}_\d{2}_\d{2}_'.$key.'_'.$sliceNumber.'|');
                }
                $profileName=$imagingType['name'];
                if(!$images){
                    Logger::debug("No images for imaging profile $key ($profileName)");
                } else {
                    $imageCount=count($images);
                    Logger::info("Found $imageCount images for imaging profile $key ($profileName)");

                    //Does imagingsession exist? Does its image count match?
                    $existingInspection=null;
                    $existingImageCount=0;
                    Logger::debug('Looking for existing IceBear imagingsession');
                    foreach ($iceBearInspections as $iceBearInspection){
                        Logger::debug($iceBearInspection['imageddatetime'].' : IB imageddatetime');
                        Logger::debug($imagedTimeFormatted.' : Filesystem last modified time (newest image)');
                        Logger::debug($iceBearInspection['imagingparametersversionid'].' : IB inspection imagingparametersversionid');
                        Logger::debug($imagingType['profile']['currentversionid'].' : IB profile imagingparametersversionid');
                        if($iceBearInspection['imagingparametersversionid']!=$imagingType['profile']['currentversionid']){
                            //Imaging profile does not match
                            Logger::debug('Profile mismatch, not using this imagingsession');
                        } else {
                            //Imaging profile matches
                            $fudgeMinutes=abs(1*($config['duplicateInspectionBufferMinutes']));
                            $fudgeSeconds=60*$fudgeMinutes;
                            $maxImagedTime=$imagedTime+$fudgeSeconds;
                            $minImagedTime=$imagedTime-$fudgeSeconds;
                            $inspectionTime=strtotime($iceBearInspection['imageddatetime']);
                            if($inspectionTime>=$minImagedTime && $inspectionTime <= $maxImagedTime) {
                                //Imaging time within acceptable range
                                Logger::debug('Profile matches, datetime within acceptable range. Using this imagingsession.');
                                $existingInspection=$iceBearInspection;
                                $existingImageCount=1*$iceBearInspection['numImages'];
                                break;
                            } else {
                                //Imaging time not within acceptable range
                                Logger::debug('Datetime mismatch, not using this imagingsession');
                            }
                        }
                    }
                    $createdInspection=false;
                    if($existingInspection) {
                        Logger::info('Found existing IceBear imagingsession');
                    } else {
                        Logger::info('No existing IceBear imagingsession');
                        //Create
                        $existingInspection=iceBearRequest('createInspection',[
                            'imagedDateTime'=>$imagedTimeFormatted,
                            'profileName'=>$profileName,
                            'lightType'=>$imagingType['light'],
                            'imagerName'=>$imager['name'],
                            'temperature'=>$imager['nominalTemperature'],
                            'plateBarcode'=>$barcode
                        ],$config);
                        $createdInspection=true;
                    }

                    Logger::info("IceBear imagingsession has $existingImageCount images, local inspection has $imageCount");
                    if($existingImageCount<$imageCount){
                        Logger::info('Pushing images to IceBear');
                        createImages($barcode, $inspection, $existingInspection['id'], $images, $imager, $config);
                        Logger::info('Pushed images to IceBear');
                    } else {
                        Logger::info("Not pushing images to IceBear");
                        if(!$createdInspection){
                            if(isset($config['reimportBarcode']) && $config['reimportBarcode']===$barcode){
                                Logger::info('Not incrementing completed inspections count for re-imported plate');
                            } else {
                                $completedInspectionsFound++;
                                if($stopAfterCompletedInspectionsFound && $completedInspectionsFound>=$stopAfterCompletedInspectionsFound){
                                    Logger::info("Found $completedInspectionsFound completed inspections (exist, image count matches");
                                    Logger::info("Maximum is $stopAfterCompletedInspectionsFound");
                                    Logger::info("Stopping processing inspections");
                                    $completedLimitReached=true;
                                } else if($stopAfterCompletedInspectionsFound){
                                    Logger::info("Found $completedInspectionsFound completed inspections (exist, image count matches");
                                    Logger::info("Maximum is $stopAfterCompletedInspectionsFound");
                                    Logger::info("Continuing to process inspections");
                                } else {
                                    Logger::warning("Not stopping on completed inspections due to config");
                                    Logger::warning("Will import all the way back to config importFromDate");
                                }
                            }
                        }
                    }
                }
                if($completedLimitReached){ break; }
            }
            if($completedLimitReached){ break; }
        }
        if($completedLimitReached){ break; }
    }
    Logger::debug('Returning from importInspections');
}


/**
 * @throws Exception
 */
function createImages($plateBarcode, $inspectionNumber, $imagingSessionId, $imageFilenames, $imager, $config){
    Logger::debug('In createImages');
    Logger::debug('imagingSessionId='.$imagingSessionId);
    $imagesDirectory=rtrim($imager['imagesDirectory'],'/')."/$plateBarcode/$inspectionNumber/";
    $thumbsDirectory=rtrim($imager['thumbnailsDirectory'],'/')."/$plateBarcode/$inspectionNumber/Thumbnails/160/";
    $imagesPerPush=(int)$config['imagesPerPush'];
    $images=[];
    $imageFilenames=array_reverse($imageFilenames); ///Got the newest images first. Push the oldest first.
    foreach ($imageFilenames as $filename){
        $parts=parseImageFilename($filename, $plateBarcode);
        $image=[
            'rowNumber'=>1*$parts['row'],
            'columnNumber'=>1*$parts['col'],
            'subPositionNumber'=>1*$parts['sub'],
        ];
        Logger::debug('Image path is '.$imagesDirectory.$filename);
        $fileContents=@file_get_contents($imagesDirectory.$filename);
        if(!$fileContents){
            throw new Exception('Could not open image file for encoding');
        }
        $image['imageData']=convert_uuencode($fileContents);

        $thumbFilename=str_replace('_RAI.', '_RAI_160.', $filename);
        if(!file_exists($thumbsDirectory.$thumbFilename)) {
            Logger::debug('No thumbnail at '.$thumbsDirectory.$thumbFilename);
        } else {
            Logger::debug('Found thumbnail at '.$thumbsDirectory.$thumbFilename);
            //TODO Why, when this doesn't fire, isn't the receiver resizing the thumbnail?

            $fileContents=@file_get_contents($thumbsDirectory.$thumbFilename);
            if(!$fileContents){
                throw new Exception('Could not open image file for encoding');
            }
            $image['thumbData']=convert_uuencode($fileContents);
        }

        if(isset($imager['micronsPerPixel'])){
            $image['micronsPerPixel']=$imager['micronsPerPixel'];
        }

        $images[]=$image;
        if(count($images)>=$imagesPerPush){
            iceBearRequest('createImages',[
                'images'=>$images,
                'iceBearImagingSessionId'=>$imagingSessionId
            ],$config);
            $images=[];
        }
    }
    if(!empty($images)){
        iceBearRequest('createImages',[
            'images'=>$images,
            'iceBearImagingSessionId'=>$imagingSessionId
        ],$config);
    }
    Logger::debug('Returning from createImages');
}


/**
 * RM01_03_000_0213_Proj1_Clon1_MC_0000MC000518_002_150730_01_01_02_00_99_031_001_RAI
 * Discard everything up to and including the barcode and its trailing underscore: 002_150730_01_01_02_00_99_031_001_RAI
 * Explode on underscores.
 * 002 - Second inspection on this plate?
 * 150730 - Date of inspection in YYMMDD format. If you're reading this in the year 2100, sorry. Wasn't me.
 * 01 - Row (1-based)
 * 01 - Col (1-based)
 * 02 - Drop (1-based)
 * 00 - Imaging profile, see imagingTypes in config
 * 99 - Slice number. 99 is (a copy of) the best slice, 00 is a composite ("Extended-focus image" in Formulatrix) and the one we want.
 * 031 - Unknown
 * 001 - Unknown
 * RAI - Unknown
 * @throws Exception
 */
function parseImageFilename($filename, $barcode){
    Logger::debug('In parseImageFilename');
    $filename=explode($barcode.'_', $filename);
    $parts=explode('_',$filename[1]);
    if(10!==count($parts)){
        throw new Exception('Bad filename in parseImageFilename; should be 10 parts after barcode');
    }
    $result=[
        'row'=>$parts[3],
        'col'=>$parts[2],
        'sub'=>$parts[4],
        'profile'=>$parts[5]
    ];
    foreach ($result as $k=>$v){
        Logger::debug("$k = $v");
    }
    Logger::debug('Returning from parseImageFilename');
    return $result;
}

/**
 * @param $directory
 * @param $barcode
 * @param $imagingProfileAbbreviation
 * @return int[]|string[]
 * @throws Exception
 */
function getImagesForInspectionAndProfile($directory, $barcode, $imagingProfileAbbreviation){
    Logger::debug('In getImagesForInspectionAndProfile');
    Logger::debug("directory=$directory");
    Logger::debug("barcode=$barcode");
    Logger::debug("imagingProfileAbbreviation=$imagingProfileAbbreviation");
    if(!preg_match('/^[A-Z0-9]{2}$/', $imagingProfileAbbreviation)){
        throw new Exception('Bad imaging profile');
    }
    Logger::info('Getting composite images');
    $slice='00'; //Composite ("extended-focus" images) preferred
    Logger::info($directory);
    $images=getDirectoryListingSortedLastModifiedDescending($directory, '/^.*'.$barcode.'_\d{3}_\d{6}_\d{2}_\d{2}_\d{2}_'.$imagingProfileAbbreviation.'_'.$slice.'.*\.jpg$/');
    if(empty($images)){
        Logger::info('No composite images found, getting best-slice images instead');
        $images=getDirectoryListingSortedLastModifiedDescending($directory, '/^.*'.$barcode.'_\d{3}_\d{6}_\d{2}_\d{2}_\d{2}_'.$imagingProfileAbbreviation.'_'.$slice.'.*\.jpg$/');
    }
    Logger::debug('Returning from getImagesForInspectionAndProfile');
    return $images;
}

/**
 * @param $config
 * @return void
 * @throws Exception
 */
function updateDiskSpace($config){
    Logger::debug('In updateDiskSpace');
    if(!$config['updateDiskSpace']) {
        Logger::info('Not updating disk space, because it is disabled in config');
        Logger::debug('Returning from updateDiskSpace');
        return;
    }
    if (strtoupper(substr(PHP_OS, 0, 3)) !== 'LIN') {
        Logger::warning('Not updating disk space, because the OS is not Linux');
        Logger::warning('Set updateDiskSpace to 0 in config file to remove these warnings');
        Logger::debug('Returning from updateDiskSpace');
        return;
    }
    $drives=shell_exec('df');
    $drives=explode("\n",$drives);

    $headers=trim(preg_replace('/^[A-Za-z]/','',array_shift($drives)));
    if('FilesystemSizeUsedAvailUseMountedon'!=$headers) {
        Logger::warning('Not updating disk space because df did not output in the expected format');
        Logger::warning('Set updateDiskSpace to 0 in config file to remove these warnings');
        Logger::debug('Returning from updateDiskSpace');
        return;
    }
    Logger::info('Updating disk space');
    $disks=array();
    foreach($drives as $disk) {
        $disk=trim($disk);
        if (''==$disk) {
            continue;
        }
        $disk=preg_split('/\s+/', $disk);
        if (count($disk)!==6) {
            Logger::error("Disk information should have 6 columns. Validation above should have failed.");
            throw new Exception('Disk usage information was not in an expected format.');
        }

        //Not interested if below 50GB
        $total = 1*$disk[1] ;
        if($total < DISK_MINIMUM_GIGABYTES*1024*1024*1024){
            Logger::debug('Ignoring filesystem '.$disk[0].' (smaller than '.DISK_MINIMUM_GIGABYTES.'GB)');
            continue;
        }
        if (false !== stripos($total, 'K') || false !== stripos($total, 'M') ||
            (false !== stripos($total, 'G') && (int)$total < 50)) {
            continue;
        }

        $disks[] = array(
            'machine' => 'Rigaku',
            'filesystem' => $disk[0],
            'bytesused' => $disk[2],
            'bytesfree' => $disk[3],
            'label' => $disk[5],
        );
    }
    iceBearRequest('updateDiskSpaces', ['drives'=>$disks], $config);

    Logger::debug('Returning from updateDiskSpace');
}

/**
 * @param $action
 * @param $data
 * @param $config
 * @param $redirectUrl
 * @return array
 * @throws Exception
 */
function iceBearRequest($action, $data, $config, $redirectUrl=null){
    Logger::debug("In iceBearRequest, action=$action");
    if(!$data){ $data=["dummy"=>"dummy"]; }
    $iceBearUrl=rtrim($config['iceBear']['url'],'/').'/device/'.ICEBEAR_DEVICE_SCOPE.'/'.$action;
    if(!empty($redirectUrl)){ $iceBearUrl=$redirectUrl; }
    Logger::debug("POSTing to $iceBearUrl");
    $apiKey=$config['iceBear']['apiKey'];

    $ch = curl_init($iceBearUrl);
    $options = array(
        CURLOPT_POST => 1,
        CURLOPT_HTTPHEADER =>array('Content-Type: application/json','Authorization: '.$apiKey),
        CURLOPT_POSTFIELDS => json_encode($data),
        CURLOPT_COOKIEFILE => '',
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_RETURNTRANSFER  => true,
        CURLOPT_HEADER          => false,
        CURLOPT_CONNECTTIMEOUT  => 20,
        CURLOPT_TIMEOUT         => 20,
        CURLOPT_SSL_VERIFYHOST  => 0,
        CURLOPT_SSL_VERIFYPEER  => false,
        CURLOPT_UNRESTRICTED_AUTH => true
    );

    curl_setopt_array($ch,$options);
    $result = curl_exec($ch);
    $httpStatusCode=curl_getinfo($ch, CURLINFO_HTTP_CODE);
    $redirectUrl=curl_getinfo($ch, CURLINFO_REDIRECT_URL);
    curl_close($ch);
    if(empty($redirectUrl)){
        Logger::debug($result);
        $result=json_decode($result,true);
        if(false!==$result){
            Logger::debug('Valid JSON received');
            if(isset($result['error'])){
                Logger::error('IceBear returned an error');
                Logger::error('Message: '.$result['error']);
                if(isset($result['thrownat'])){
                    Logger::error('Thrown at: '.$result['thrownat']);
                }
                if(isset($result['trace'])) {
                    $prefix = "Trace: ";
                    foreach ($result['trace'] as $trace){
                        Logger::error($prefix . $trace);
                        $prefix = '       ';
                    }
                }
                throw new Exception("IceBear returned error: ".$result['error']." (HTTP $httpStatusCode)");
            }
            Logger::debug('Returning from iceBearRequest');
            return $result;
        }
        throw new Exception("IceBear returned invalid JSON, with HTTP status code $httpStatusCode");
    }
    //If we got here, it's a redirect
    Logger::debug('Request redirected to '.$redirectUrl);
    Logger::debug('Returning from iceBearRequest');
    return iceBearRequest($action, $data, $config, $redirectUrl);
}

/**
 * @param $errorName
 * @param $errorMessage
 * @param $config
 * @return void
 * @throws Exception
 */
function handleErrorCondition($errorName, $errorMessage, $config){
    if(!isset($config['errorHandling']) || !isset($config['errorHandling'][$errorName])){
        Logger::error('An unexpected condition occurred and no handler was defined in config:');
        Logger::error($errorMessage);
        Logger::error('Treating it as an error and throwing an exception');
        throw new Exception($errorMessage);
    }
    $howToHandle=$config['errorHandling'][$errorName];
    if(!in_array($howToHandle, [ ERROR_HANDLING_STOP, ERROR_HANDLING_WARN, ERROR_HANDLING_IGNORE])){
        Logger::error('An unexpected condition occurred and a bad handler was defined in config:');
        Logger::error($errorMessage);
        Logger::error('Treating it as an error and throwing an exception');
        throw new Exception($errorMessage);
    }
    if(ERROR_HANDLING_IGNORE==$howToHandle){
        Logger::info($errorMessage);
    } else if(ERROR_HANDLING_WARN==$howToHandle){
        Logger::warning($errorMessage);
    } else {
        Logger::error($errorMessage);
        Logger::error('Stopping because errorHandling config told us to');
        throw new Exception($errorMessage);
    }
}

/**
 * @param string $path
 * @param string $regex
 * @return int[]|string[]
 * @throws Exception
 */
function getDirectoryListingSortedLastModifiedDescending(string $path, string $regex='/.*/'): array {
    Logger::debug('In getDirectoryListingSortedLastModifiedDescending');
    Logger::debug('$path='.$path);
    Logger::debug('$regex='.$regex);
    if(!file_exists($path) || !is_dir($path)){
        throw new Exception("$path does not exist, cannot be read, or is not a directory");
    }
    $files=[];
    $path=rtrim($path, '/').'/';
    $listing=scandir($path);
    foreach ($listing as $filename){
        if(!preg_match($regex, $filename)){ continue; }
        $files[$filename]=filemtime($path.$filename);
    }
    arsort($files);
    Logger::debug('Returning from getDirectoryListingSortedLastModifiedDescending');
    return array_keys($files);
}

class Logger {

    const DAYS_TO_KEEP=7;

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

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

    private static $logLevel;
    private static $logFileHandle;
    private static $inited = false;

    /**
     * Initiates logging. DOES NOT handle log rotation; must be handled by caller or otherwise.
     * @param string|null $logFileName The file to append to (creating if needed), or empty to echo logs to output. If
     *          specified, is written in the IceBear log directory (/var/log/icebear by default).
     * @param int $logLevel One of the constants Logger::LOGLEVEL_DEBUG, Logger::LOGLEVEL_INFO, Logger::LOGLEVEL_WARN, Logger::LOGLEVEL_ERROR.
     * @throws Exception
     */
    public static function init(int $logLevel, string $logFileName = null){
        if (!empty($logFileName)) {
            if (!preg_match('/^[A-Za-z0-9._-]+$/', $logFileName)) {
                throw new Exception("Bad log filename $logFileName in Logger::init()");
            }
            $logDirectory = '/var/log/icebear/';
            if (!file_exists($logDirectory)) {
                $logDirectory = 'C:/icebearlogs/'; //For dev on Windows
            }
            if (!file_exists($logDirectory)) {
                throw new Exception('IceBear log directory does not exist: ' . $logDirectory);
            }
            $logFilePath = $logDirectory . $logFileName;
            static::$logFileHandle = @fopen($logFilePath, 'a');
            if (!static::$logFileHandle) {
                throw new Exception('Could not open log file ' . $logFilePath . ' for appending.');
            }
        }
        static::$logLevel = $logLevel;
        static::$inited = true;
    }

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

    /**
     * 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){
        if (!self::$inited) {
            throw new Exception('Log not init()ed.');
        }
        if ($logLevel < self::$logLevel) { return; }
        $labels = self::LOG_LABELS;
        $out = $labels[$logLevel] . ' ' . gmdate('d M y H:i:s') . ' ' . $message . "\n";
        if (self::$logFileHandle) {
            fwrite(self::$logFileHandle, $out);
            fflush(self::$logFileHandle);
        } else {
            echo $out;
        }
    }

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


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

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

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


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

}
