<?php
/**
 * ______________________________________________________________________
 * 
 *  _____                 _       _             _                        
 * /  __ \               (_)     | |           (_)                       
 * | /  \/_ __ ___  _ __  _  ___ | |__   __   ___  _____      _____ _ __ 
 * | |   | '__/ _ \| '_ \| |/ _ \| '_ \  \ \ / / |/ _ \ \ /\ / / _ \ '__|
 * | \__/\ | | (_) | | | | | (_) | |_) |  \ V /| |  __/\ V  V /  __/ |   
 *  \____/_|  \___/|_| |_| |\___/|_.__/    \_/ |_|\___| \_/\_/ \___|_|   
 *                      _/ |                                             
 *                     |__/                                              
 * ______________________________________________________________________
 * 
 * The cronjob viewer for centralized monitoring of cronjobs using
 * Axels cronrwapper (see <https://github.com/axelhahn/cronwrapper>).
 * 
 * You can browse all servers to see 
 * - the last status of all cronjobs
 * - results an execution times of running jonbs
 * - a timeline for all jobs running > 60s
 * 
 * 
 * Free software. Open Source. GNU GPL 3.
 * SOURCE: <https://git-repo.iml.unibe.ch/iml-open-source/cronlog-viewer>
 * 
 * ______________________________________________________________________
 * 
 * The cronlog class contains non visual methods
 * @see cronlog-renderer.class.php
 *
 * @license GNU GPL 3.0
 * @author Axel Hahn <axel.hahn@iml.unibe.ch>
 * 
 * 2024-09-20  <axel.hahn@unibe.ch>  added type declarations; update php docs
 */

class cronlog
{


    /**
     * Data dir with subdirs per host and its cronjob logs
     * @var string
     */
    protected string $_sDataDir = "__APPDIR__/data";

    /**
     * TTL for cached data
     * @var int
     */
    protected int $_iTtlCache = 60; // in sec

    /**
     * When show an error for expired jobs (keep in mind the latency to execute job and sync logs)
     * @var integer
     */
    protected int $_iExpiredJobsFailAfter = 60 * 30; // in sec

    /**
     * Minimal TTL for a cronjob
     * @var int
     */
    protected int $_iMinTtl = 0; // in sec

    /**
     * List of cronjobs to hide in the web view
     * @var array
     */
    protected array $_aSkipJoblogs = [];

    protected array $_aInstances = [];

    protected array $_aServers = [];
    protected string $_sActiveServer = '';

    protected string $_sFileFilter_serverjoblog = '*joblog*.done';
    protected string $_sFileFilter_joblog = '*.log';
    protected string $_sFileFilter_jobrunning = '*.log.running*';

    protected string $_sLang = ''; // language ... read from config file
    protected array $_aLang = []; // language data

    // ----------------------------------------------------------------------
    // MAIN
    // ----------------------------------------------------------------------

    /**
     * Constructor
     */
    public function __construct()
    {
        // read config
        if (file_exists(__DIR__ . '/../config/inc_cronlog.php')) {
            $aCfgTemp = include(__DIR__ . '/../config/inc_cronlog.php');
            $this->_sDataDir = isset($aCfgTemp['sDatadir']) ? $aCfgTemp['sDatadir'] : $this->_sDataDir;
            $this->_iTtlCache = isset($aCfgTemp['iTtlCache']) ? (int) $aCfgTemp['iTtlCache'] : $this->_iTtlCache;
            $this->_iMinTtl = isset($aCfgTemp['iMinTtl']) ? (int) $aCfgTemp['iMinTtl'] : $this->_iMinTtl;
            $this->_iExpiredJobsFailAfter = isset($aCfgTemp['iExpiredJobsFailAfter']) ? (int) $aCfgTemp['iExpiredJobsFailAfter'] : $this->_iExpiredJobsFailAfter;
            $this->_aSkipJoblogs = isset($aCfgTemp['aHidelogs']) && is_array($aCfgTemp['aHidelogs']) ? $aCfgTemp['aHidelogs'] : $this->_aSkipJoblogs;
            $this->_aInstances = isset($aCfgTemp['instances']) ? $aCfgTemp['instances'] : [];

            $this->_sLang = isset($aCfgTemp['lang']) && $aCfgTemp['lang'] ? $aCfgTemp['lang'] : 'en-en';
            if (!file_exists(__DIR__ . '/../config/lang_' . $this->_sLang . '.php')) {
                header('HTTP/1.1 503 Service Temporarily Unavailable');
                header('Status: 503 Service Temporarily Unavailable');
                die('ERROR: lang file for lang => "' . $this->_sLang . '" not found.<br>config/lang_' . $this->_sLang . '.php<br>does not exist.');
            }
            $this->_aLang = $aCfgTemp = include(__DIR__ . '/../config/lang_' . $this->_sLang . '.php');
        }
        $this->_sDataDir = str_replace("__APPDIR__", dirname(dirname(__FILE__)), $this->_sDataDir);
        $this->_sDataDir = str_replace('\\', '/', $this->_sDataDir);

        $this->getServers();
    }

    // ----------------------------------------------------------------------
    // private
    // ----------------------------------------------------------------------


    /**
     * chaching: get the full path of directory for caching
     * @return string
     */
    protected function _getCacheDir(): string
    {
        return $this->_sDataDir . '/__cache';
    }

    /**
     * Caching: get full path of a caching item
     * @param string $sTaskId  Name of a task
     * @return string
     */
    protected function _getCacheFile(string $sTaskId): string
    {
        return $this->_getCacheDir() . '/' . $sTaskId;
    }

    /**
     * Read logs: get full path to a servers cronjob logdata
     * @return string
     */
    protected function _getServerlogDir(): string
    {
        return $this->_sDataDir . '/' . $this->_sActiveServer;
    }

    /**
     * Caching: get cached data if they exist and aren't expired
     * @param  string  $sTaskId  Name of the task
     * @return mixed string|array
     */
    protected function _getCacheData(string $sTaskId): string|array
    {
        // DISABLE CACHE return false;
        $sFile = $this->_getCacheFile($sTaskId);
        if (file_exists($sFile)) {
            if (filemtime($sFile) > (date('U') - $this->_iTtlCache)) {
                // echo "USE cache $sFile<br>";
                return unserialize(file_get_contents($sFile));
            } else {
                // echo "DELETE cache $sFile<br>";
                unlink($sFile);
            }
        }
        return '';
    }
    /**
     * Read logs: parse a single line in the joblog and return has with all key value items
     * @param  string  $sLine  single line in the log
     * @return array
     */
    protected function _parseJoblogLine(string $sLine): array
    {
        $aReturn = [];
        // echo "DEBUG $sLine<br>";
        // job=dok-kvm-instances:host=kalium:start=1538358001:end=1538358001:exectime=0:ttl=60:rc=0
        $sLine = str_replace("\n", '', $sLine);
        $sLine = str_replace(':', '", "', $sLine);
        $sLine = str_replace('=', '": "', $sLine);
        $sLine = '{"' . $sLine . '"}';
        // echo "DEBUG $sLine<br><br>";
        $aReturn = json_decode($sLine, 1);
        if (!is_array($aReturn)) {
            echo "not a JSON string<br>";
            echo "DEBUG $sLine<br><br>";
            die();
        }
        return $aReturn;
    }

    /**
     * Read logs: parse the whole cronwrapper logfile and return a hash
     * @param  string  $sFile  filename with full path
     * @return array
     */
    protected function _parseLogfile(string $sFile): array
    {
        $aReturn = [
            'SCRIPTNAME' => false,
            'SCRIPTTTL' => false,
            'SCRIPTSTARTTIME' => false,
            'SCRIPTLABEL' => false,
            'SCRIPTENDTIME' => false,
            'SCRIPTEXECTIME' => false,
            'SCRIPTRC' => false,
            // 'SCRIPTOUT'=>[],
        ];
        $fileHandle = fopen($sFile, "r");
        while (($line = fgets($fileHandle)) !== false) {
            // get key ... the part before "="
            $sKey = trim(preg_replace('/=.*/', '', $line));
            if ($sKey && isset($aReturn[$sKey])) {
                // add value ... the part behind "="
                $aReturn[$sKey] = preg_replace('/^([A-Z]*\=)/', '', $line);
            }
        }
        fclose($fileHandle);

        // fetch unit timestamp from date values (they are like "2018-09-30 03:40:05, 1538278805")
        $aReturn['SCRIPTSTARTTIME'] = (int) preg_replace('/.*,\ /', '', $aReturn['SCRIPTSTARTTIME']);
        $aReturn['SCRIPTENDTIME'] = (int) preg_replace('/.*,\ /', '', $aReturn['SCRIPTSTARTTIME']);

        // remove " s" from exec time value
        $aReturn['SCRIPTEXECTIME'] = preg_replace('/\ s$/', '', $aReturn['SCRIPTEXECTIME']);

        return $aReturn;

    }

    /**
     * caching: write new data; it returns the success of write operation as bool
     * @param  string  $sTaskId
     * @param  mixed   $data      data to store; can be any serializable value
     * @return boolean|integer
     */
    protected function _writeCacheData(string $sTaskId, mixed $data): bool|int
    {
        $sFile = $this->_getCacheFile($sTaskId);
        // echo "WRITE cache $sFile<br>";
        return file_put_contents($sFile, serialize($data));
    }

    // ----------------------------------------------------------------------
    // public getter
    // ----------------------------------------------------------------------

    /**
     * Get currently selected server
     * @return string
     */
    public function getServer(): string
    {
        return $this->_sActiveServer;
    }

    /**
     * Get array with existing servers in data dir
     * @return array
     */
    public function getServers(): array
    {

        if (is_array($this->_aServers) && count($this->_aServers)) {
            return $this->_aServers;
        }

        $this->_aServers = [];
        // echo "DEBUG DATADIR: " . $this->_sDataDir."<br>";
        if (!is_dir($this->_sDataDir)) {
            echo "WARNING: no data. Check sDatadir in the config and set it to an existing directory.<br>";
            die();
        }

        if ($handle = opendir($this->_sDataDir)) {
            while (false !== ($entry = readdir($handle))) {
                if ($entry != "." && $entry != ".." && $entry != "__cache" && is_dir($this->_sDataDir . '/' . $entry)) {
                    // echo "DEBUG $entry<br>\n";
                    $this->_aServers[$entry] = [];
                }
            }
            closedir($handle);
        }
        ksort($this->_aServers);
        return $this->_aServers;
    }

    /**
     * Get logs from jobfilea of the current or given server
     * @param  boolean  $bUseSkip  hide jobs if their label matches the skip list; default: true
     * @return array
     */
    public function getServerJobHistory(bool $bUseSkip = true): bool|array
    {
        $aReturn = [];
        $sTaskId = __FUNCTION__ . '-' . $this->_sActiveServer;

        $aData = $this->_getCacheData($sTaskId);
        if ($aData) {
            return $aData;
        }

        $aData = [];

        foreach (glob($this->_getServerlogDir() . '/' . $this->_sFileFilter_serverjoblog) as $sMyJobfile) {
            // echo "DEBUG: $sMyJobfile<br>";
            $fileHandle = fopen($sMyJobfile, "r");
            while (($line = fgets($fileHandle)) !== false) {
                // send the current file part to the browser
                $aData = $this->_parseJoblogLine($line);
                if (!$bUseSkip || array_search($aData['job'], $this->_aSkipJoblogs) === false) {
                    $aReturn[$aData['start'].'__'.$aData['host'].'__'.$aData['job']] = $aData;
                }
            }
            fclose($fileHandle);
        }
        krsort($aReturn);
        $this->_writeCacheData($sTaskId, $aReturn);
        return $aReturn;
    }


    /**
     * Get logs from jobfilea of the current or given server
     * @return array
     */
    public function getServersLastLog(): array
    {
        $aReturn = [];
        $aData = [];
        foreach (glob($this->_getServerlogDir() . '/' . $this->_sFileFilter_joblog) as $sMyJobfile) {
            // echo "DEBUG: log file $sMyJobfile<br>";
            $aData = $this->_parseLogfile($sMyJobfile);
            $aData['server'] = $this->_sActiveServer;
            $aData['logfile'] = $this->_sActiveServer . '/' . basename($sMyJobfile);
            $aReturn[$aData['SCRIPTSTARTTIME'] . $sMyJobfile] = $aData;
        }
        rsort($aReturn);
        return $aReturn;
    }

    /**
     * get logs from jobfilea of the current or given server
     * @return array
     */
    public function getRunningJobs(): array
    {
        $aReturn = [];
        $aData = [];
        foreach (glob($this->_getServerlogDir() . '/' . $this->_sFileFilter_jobrunning) as $sMyJobfile) {
            // echo "DEBUG: log file $sMyJobfile<br>";
            $aData = $this->_parseLogfile($sMyJobfile);
            $aData['server'] = $this->_sActiveServer;
            $aData['logfile'] = $this->_sActiveServer . '/' . basename($sMyJobfile);
            $aReturn[$aData['SCRIPTSTARTTIME'] . $sMyJobfile] = $aData;
        }
        rsort($aReturn);
        return $aReturn;
    }

    /**
     * Translate: get a language specific text of a given key
     * @param  string  $id  id of language text
     * @return string
     */
    public function t($id): string
    {
        return $this->_aLang[$id] ?? '[' . $id . '] ???';
    }
    // ----------------------------------------------------------------------
    // public setter
    // ----------------------------------------------------------------------

    /**
     * Set which server is selected
     * The given server must exist as directory (that contains its logs)
     * It returns false if the given server does not exist or has value 'ALL'
     * @param  string  $sServer  server name
     * @return bool|string
     */
    public function setServer(string $sServer): bool|string
    {
        $this->_sActiveServer = false;
        if ($sServer === 'ALL') {
            return false;
        }
        if ($sServer && !array_key_exists($sServer, $this->_aServers)) {
            echo "WARNING: server [$sServer] does not exist<br>";
            return false;
        }
        $this->_sActiveServer = $sServer;

        return $this->_sActiveServer;
    }

}