<?php
// Copyright (C) 2010-2024 Combodo SAS
//
//   This file is part of iTop.
//
//   iTop is free software; you can redistribute it and/or modify
//   it under the terms of the GNU Affero General Public License as published by
//   the Free Software Foundation, either version 3 of the License, or
//   (at your option) any later version.
//
//   iTop is distributed in the hope that it will be useful,
//   but WITHOUT ANY WARRANTY; without even the implied warranty of
//   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
//   GNU Affero General Public License for more details.
//
//   You should have received a copy of the GNU Affero General Public License
//   along with iTop. If not, see <http://www.gnu.org/licenses/>


/**
 * @since 2.7.0 N°2518 N°2793
 */
interface iLogFileNameBuilder
{
	/**
	 * @param string $sLogFileFullPath full path name for the log file
	 */
	public function __construct($sLogFileFullPath = null);

	/**
	 * @return string log file path we will write new log entry to
	 */
	public function GetLogFilePath();
}

class DefaultLogFileNameBuilder implements iLogFileNameBuilder
{
	private $sLogFileFullPath;

	/**
	 * @inheritDoc
	 */
	public function __construct($sLogFileFullPath = null)
	{
		$this->sLogFileFullPath = $sLogFileFullPath;
	}

	/**
	 * @inheritDoc
	 */
	public function GetLogFilePath()
	{
		return $this->sLogFileFullPath;
	}
}

/**
 * Adds a suffix to the filename
 *
 * @since 2.7.0 N°2518 N°2793
 */
abstract class RotatingLogFileNameBuilder implements iLogFileNameBuilder
{
	/**
	 * Test is done each time to cover edge case like session beginning at 23:59 and ending at 00:01
	 * We are caching the file mtime though
	 * @var array with full file path as key and DateTime (file last modification time) as value
	 */
	protected static $aLogFileLastModified = array();
	/** @var string */
	protected $sLogFileFullPath;
	/** @var string */
	protected $sFilePath;
	/** @var string */
	protected $sFileBaseName;
	/** @var string */
	protected $sFileExtension;

	/**
	 * @inheritDoc
	 */
	public function __construct($sLogFileFullPath = null)
	{
		$this->sLogFileFullPath = $sLogFileFullPath;
	}

	protected function GetLastModifiedDateForFile()
	{
		if (isset(static::$aLogFileLastModified[$this->sLogFileFullPath]))
		{
			return static::$aLogFileLastModified[$this->sLogFileFullPath];
		}

		return null;
	}

	protected function SetLastModifiedDateForFile($oDateTime)
	{
		static::$aLogFileLastModified[$this->sLogFileFullPath] = $oDateTime;
	}

	/**
	 * Need to be called when the file is rotated : actually the next call will need to check on the real date modified instead of using
	 * the previously  cached value !
	 */
	public function ResetLastModifiedDateForFile()
	{
		static::$aLogFileLastModified[$this->sLogFileFullPath] = null;
	}

	/**
	 * @inheritDoc
	 *
	 * Doing the check before opening and writing the log file. There is also a iProcess but cron can be disabled...
	 *
	 * @see \LogFileRotationProcess the iProcess impl
	 */
	public function GetLogFilePath()
	{
		$this->CheckAndRotateLogFile();
		return $this->sLogFileFullPath;
	}

	/**
	 * Check log last date modified. If too old then rotate the log file (move it to a new name with a suffix)
	 *
	 * @uses filemtime() to get log file date last modified
	 */
	public function CheckAndRotateLogFile()
	{
		$oConfig = utils::GetConfig();
		utils::InitTimeZone($oConfig);

		if ($this->GetLastModifiedDateForFile() === null)
		{
			if (!$this->IsLogFileExists())
			{
				return;
			}

			$iLogDateLastModifiedTimeStamp = filemtime($this->sLogFileFullPath);
			if ($iLogDateLastModifiedTimeStamp === false)
			{
				return;
			}
			$oDateTime = DateTime::createFromFormat('U', $iLogDateLastModifiedTimeStamp);
			$sItopTimeZone = $oConfig->Get('timezone');
			$timezone = new DateTimeZone($sItopTimeZone);
			$oDateTime->setTimezone($timezone);
			$this->SetLastModifiedDateForFile($oDateTime);
		}

		$oNow = new DateTime();
		$bShouldRotate = $this->ShouldRotate($this->GetLastModifiedDateForFile(), $oNow);
		if (!$bShouldRotate)
		{
			return;
		}

		$this->RotateLogFile($this->GetLastModifiedDateForFile());
	}

	/**
	 * Rotate current log file
	 *
	 * @param DateTime $oLogFileLastModified date when the log file was last modified
	 *
	 * @uses \iTopMutex instead of flock as doing a rename on a file with a flock cause an error on PHP 5.6.40 Windows (ok on 7.3.15 though)
	 * @uses GetRotatedFileName to get rotated file name
	 */
	protected function RotateLogFile($oLogFileLastModified)
	{
		if (!$this->IsLogFileExists()) // extra check, but useful for cron also !
		{
			return;
		}

		$oLock = null;
		try
		{
			$oLock = new iTopMutex('log_rotation_'.$this->sLogFileFullPath);
			$oLock->Lock();
			if (!$this->IsLogFileExists()) // extra extra check if we were blocked and another process moved the file in the meantime
			{
				$oLock->Unlock();
				return;
			}
			$this->ResetLastModifiedDateForFile();
			$sNewLogFileName = $this->GetRotatedFileName($oLogFileLastModified);
			rename($this->sLogFileFullPath, $sNewLogFileName);
		}
		catch (Exception $e)
		{
			// nothing to do, cannot log... file will be renamed on the next call O:)
			return;
		}
		finally
		{
			if (!is_null($oLock)) { $oLock->Unlock();}
		}
	}

	/**
	 * @param DateTime $oLogFileLastModified date when the log file was last modified
	 *
	 * @return string the full path of the rotated log file
	 * @uses static::$oLogFileLastModified
	 * @uses GetFileSuffix
	 */
	public function GetRotatedFileName($oLogFileLastModified)
	{
		$aPathParts = pathinfo($this->sLogFileFullPath);
		$this->sFilePath = $aPathParts['dirname'];
		$this->sFileBaseName = $aPathParts['filename'];
		$this->sFileExtension = $aPathParts['extension'];

		$sFileSuffix = $this->GetFileSuffix($oLogFileLastModified);

		return $this->sFilePath.DIRECTORY_SEPARATOR
			.$this->sFileBaseName
			.'.'.$sFileSuffix
			.'.'.$this->sFileExtension;
	}

	/**
	 * @return bool true if file exists and is readable
	 */
	public function IsLogFileExists()
	{
		if (!file_exists($this->sLogFileFullPath))
		{
			return false;
		}

		if (!is_readable($this->sLogFileFullPath))
		{
			return false;
		}

		return true;
	}

	/**
	 * **Warning :** both DateTime params must have the same timezone set ! Should use the iTop timezone ('timezone' config parameter)
	 *
	 * @param DateTime $oLogFileLastModified date when the log file was last modified
	 * @param DateTime $oNow date/time of the log we want to write
	 *
	 * @return bool true if the file has older informations and we need to move it to an archive (rotate), false if we don't have to
	 */
	abstract public function ShouldRotate($oLogFileLastModified, $oNow);

	/**
	 * @param DateTime $oDate log file last modification date
	 *
	 * @return string suffix for the rotated log file
	 */
	abstract protected function GetFileSuffix($oDate);

	/**
	 * @see \LogFileRotationProcess
	 *
	 * @param \DateTime $oNow current date
	 *
	 * @return DateTime time when the cron process should run
	 */
	abstract public function GetCronProcessNextOccurrence(DateTime $oNow);
}

/**
 * @since 2.7.0 N°2518 N°2793
 */
class DailyRotatingLogFileNameBuilder extends RotatingLogFileNameBuilder
{
	/**
	 * @inheritDoc
	 */
	protected function GetFileSuffix($oDate)
	{
		return $oDate->format('Y-m-d');
	}

	/**
	 * @inheritDoc
	 */
	public function ShouldRotate($oLogFileLastModified, $oNow)
	{
		$iLogYear = $oLogFileLastModified->format('Y');
		$iLogDay = $oLogFileLastModified->format('z');
		$iNowYear = $oNow->format('Y');
		$iNowDay = $oNow->format('z');

		if ($iLogYear !== $iNowYear)
		{
			return true;
		}

		if ($iLogDay !== $iNowDay)
		{
			return true;
		}

		return false;
	}

	/**
	 * @inheritDoc
	 */
	public function GetCronProcessNextOccurrence(DateTime $oNow)
	{
		$oOccurrence = clone $oNow;
		$oOccurrence->modify('tomorrow midnight');

		return $oOccurrence;
	}
}

/**
 * @since 2.7.0 N°2518 N°2793
 */
class WeeklyRotatingLogFileNameBuilder extends RotatingLogFileNameBuilder
{
	/**
	 * @inheritDoc
	 */
	protected function GetFileSuffix($oDate)
	{
		$sWeekYear = $oDate->format('o');
		$sWeekNumber = $oDate->format('W');

		return $sWeekYear.'-week'.$sWeekNumber;
	}

	/**
	 * @inheritDoc
	 */
	public function ShouldRotate($oLogFileLastModified, $oNow)
	{
		$iLogYear = $oLogFileLastModified->format('Y');
		$iLogWeek = $oLogFileLastModified->format('W');
		$iNowYear = $oNow->format('Y');
		$iNowWeek = $oNow->format('W');

		if ($iLogYear !== $iNowYear)
		{
			return true;
		}

		if ($iLogWeek !== $iNowWeek)
		{
			return true;
		}

		return false;
	}

	/**
	 * @inheritDoc
	 */
	public function GetCronProcessNextOccurrence(DateTime $oNow)
	{
		$oOccurrence = clone $oNow;
		$oOccurrence->modify('Monday next week midnight');

		return $oOccurrence;
	}
}

/**
 * @since 2.7.0 N°2820
 */
class MonthlyRotatingLogFileNameBuilder extends RotatingLogFileNameBuilder
{
	/**
	 * @inheritDoc
	 */
	public function ShouldRotate($oLogFileLastModified, $oNow)
	{
		$iLogYear = $oLogFileLastModified->format('Y');
		$iLogMonth = $oLogFileLastModified->format('n');
		$iNowYear = $oNow->format('Y');
		$iNowMonth = $oNow->format('n');

		if ($iLogYear !== $iNowYear)
		{
			return true;
		}

		if ($iLogMonth !== $iNowMonth)
		{
			return true;
		}

		return false;
	}

	/**
	 * @inheritDoc
	 */
	protected function GetFileSuffix($oDate)
	{
		$sMonthYear = $oDate->format('o');
		$sMonthNumber = $oDate->format('m');

		return $sMonthYear.'-month'.$sMonthNumber;
	}

	/**
	 * @inheritDoc
	 */
	public function GetCronProcessNextOccurrence(DateTime $oNow)
	{
		$oOccurrence = clone $oNow;
		$oOccurrence->modify('first day of next month midnight');

		return $oOccurrence;
	}
}

/**
 * @since 2.7.0 N°2518
 */
class LogFileNameBuilderFactory
{
	/**
	 * Uses the 'log_filename_builder_impl' config parameter
	 *
	 * @param string $sFileFullPath
	 *
	 * @return \iLogFileNameBuilder
	 * @throws \ConfigException
	 * @throws \CoreException
	 */
	public static function GetInstance($sFileFullPath)
	{
		$oConfig = utils::GetConfig();
		$sFileNameBuilderImpl = $oConfig->Get('log_filename_builder_impl');
		if (!is_a($sFileNameBuilderImpl, iLogFileNameBuilder::class, true))
		{
			$sFileNameBuilderImpl = 'DefaultLogFileNameBuilder';
		}

		return new $sFileNameBuilderImpl($sFileFullPath);
	}
}


/**
 * File logging
 *
 * @copyright   Copyright (C) 2010-2024 Combodo SAS
 * @license     http://opensource.org/licenses/AGPL-3.0
 * @since 2.7.0 N°2518 N°2793 file log rotation
 */
class FileLog
{
	protected $oFileNameBuilder;

	/**
	 * FileLog constructor.
	 *
	 * @param string $sFileName
	 *
	 * @throws \ConfigException
	 * @throws \CoreException
	 */
	public function __construct($sFileName = '')
	{
		$this->oFileNameBuilder = LogFileNameBuilderFactory::GetInstance($sFileName);
	}

	public function Error($sText, $sChannel = '', $aContext = array())
	{
		$this->Write($sText, __FUNCTION__, $sChannel, $aContext);
	}

	public function Warning($sText, $sChannel = '', $aContext = array())
	{
		$this->Write($sText, __FUNCTION__, $sChannel, $aContext);
	}

	public function Info($sText, $sChannel = '', $aContext = array())
	{
		$this->Write($sText, __FUNCTION__, $sChannel, $aContext);
	}

	public function Ok($sText, $sChannel = '', $aContext = array())
	{
		$this->Write($sText, __FUNCTION__, $sChannel, $aContext);
	}

	public function Debug($sText, $sChannel = '', $aContext = array())
	{
		$this->Write($sText, __FUNCTION__, $sChannel, $aContext);
	}

	public function Trace($sText, $sChannel = '', $aContext = array())
	{
		$this->Write($sText, __FUNCTION__, $sChannel, $aContext);
	}


	protected function Write($sText, $sLevel = '', $sChannel = '', $aContext = array())
	{
		$sTextPrefix = empty($sLevel) ? '' : (str_pad($sLevel, 7));
		$sTextPrefix .= ' | ';
		$sTextPrefix .= str_pad(LogAPI::GetUserInfo(), 5)." | ";

		$sTextSuffix = ' | '.(empty($sChannel) ? '' : $sChannel);
		$sTextSuffix .= ' |||';

		$sText = "{$sTextPrefix}{$sText}{$sTextSuffix}";

		$sLogFilePath = $this->oFileNameBuilder->GetLogFilePath();
		if (empty($sLogFilePath)) {
			return;
		}

		$hLogFile = @fopen($sLogFilePath, 'a');
		if ($hLogFile !== false) {
			flock($hLogFile, LOCK_EX);
			$sDate = date('Y-m-d H:i:s');
			if (empty($aContext)) {
				fwrite($hLogFile, "$sDate | $sText\n");
			} else {
				$sContext = var_export($aContext, true);
				fwrite($hLogFile, "$sDate | $sText\n$sContext\n");
			}
			fflush($hLogFile);
			flock($hLogFile, LOCK_UN);
			fclose($hLogFile);
		}
	}
}


/**
 * Simple enum like class to factorize channels values as constants
 * Channels are used especially as parameters in {@see \LogAPI} methods
 *
 * @since 2.7.5 3.0.0 N°4012
 */
class LogChannels
{
	public const APC = 'Apc';

	/**
	 * @var string Everything related to the backup / restore
	 * @since 3.1.0
	 */
	public const BACKUP = 'Backup';

	/**
	 * @since 3.0.0
	 */
	public const CLI = 'Cli';

	/**
	 * @var string
	 * @since 2.7.7 N°4558 use this new channel when logging DB transactions
	 * @since 3.0.0 logs info in CMDBSource (see commit a117906f)
	 */
	public const CMDB_SOURCE = 'CmdbSource';

	/**
	 * @since 3.0.0
	 */
	public const CONSOLE = 'Console';

	public const CORE = 'Core';

	/**
	 * @var string Everything related to the datatable component
	 * @since 3.1.0
	 */
	public const DATATABLE = 'Datatable';

	/**
	 * @var string Everything related to the data integrity
	 * @since 3.2.0
	 */
	public const DATA_INTEGRITY = 'DataIntegrity';

	public const DEADLOCK = 'DeadLock';
	/**
	 * @var string Everything related to PHP sessions tracking
	 * @since 3.1.1 3.2.0 N°6901
	 */
	public const SESSIONTRACKER = 'SessionTracker';

	/**
	 * @var string Everything related to the datamodel CRUD
	 * @since 3.1.0
	 */
	public const DM_CRUD = 'DMCrud';

	/**
	 * @var string Everything related to webrequests
	 * @since 3.1.0
	 */
	public const WEB_REQUEST = 'WebRequest';

	/**
	 * @var string Everything related to the event service
	 * @since 3.1.0
	 */
	public const EVENT_SERVICE = 'EventService';

	/**
	 * @var string
	 * @since 2.7.9 3.0.3 3.1.0 N°5588
	 */
	public const EXPORT = 'Export';

	public const INLINE_IMAGE = 'InlineImage';

	/**
	 * @var string
	 * @since 3.0.1 N°4849
	 * @since 2.7.7 N°4635
	 */
	public const NOTIFICATIONS = 'Notifications';

	public const PORTAL       = 'Portal';

	public const TEMPORARY_OBJECTS = 'TemporaryObjects';

	/**
	 * @var string
	 * @since 3.1.0
	 */
	public const ROUTER   = 'Router';

	/**
	 * @var string
	 * @since 3.2.0
	 */
	public const SECURITY = 'Security';
}


abstract class LogAPI
{
	public const CHANNEL_DEFAULT = '';

	public const LEVEL_ERROR   = 'Error';
	public const LEVEL_WARNING = 'Warning';
	public const LEVEL_INFO    = 'Info';
	public const LEVEL_OK      = 'Ok';
	public const LEVEL_DEBUG   = 'Debug';
	public const LEVEL_TRACE   = 'Trace';

	/**
	 * @see     GetMinLogLevel
	 * @used-by GetLevelDefault
	 * @var string default log level.
	 * @since 2.7.1 N°2977
	 */
	public const LEVEL_DEFAULT = self::LEVEL_OK;

	/**
	 * @see     GetMinLogLevel
	 * @used-by GetLevelDefault
	 * @var string|bool default log level when writing to DB: false by default in order to disable EventIssue creation, and so on, do not change the behavior.
	 * @since 3.0.0 N°4261
	 */
	public const LEVEL_DEFAULT_DB = false;

	protected static $aLevelsPriority = array(
		self::LEVEL_ERROR   => 400,
		self::LEVEL_WARNING => 300,
		self::LEVEL_INFO    => 200,
		self::LEVEL_OK      => 200,
		self::LEVEL_DEBUG   => 100,
		self::LEVEL_TRACE   => 50,
	);

	public const ENUM_CONFIG_PARAM_FILE = 'log_level_min';
	public const ENUM_CONFIG_PARAM_DB   = 'log_level_min.write_in_db';


	/**
	 * Parameter to enable log purge.
	 *
	 * @since 3.1.0
	 */
	public const ENUM_CONFIG_PARAM_PURGE_ENABLED = 'log_purge.enabled';

	/**
	 * Parameter to define day we want to keep old log files.
	 *
	 * @since 3.1.0
	 */
	public const ENUM_CONFIG_PARAM_PURGE_MAX_KEEP_DAYS = 'log_purge.max_keep_days';

	/**
	 * @var \Config attribute allowing to mock config in the tests
	 */
	protected static $m_oMockMetaModelConfig = null;

	protected static $oLastEventIssue = null;

	public static function Enable($sTargetFile)
	{
		// m_oFileLog is not defined as a class attribute so that each impl will have its own
		static::$m_oFileLog = new FileLog($sTargetFile);
	}

	/**
	 * @internal uses only for testing purpose.
	 */
	public static function MockStaticObjects($oFileLog, $oMetaModelConfig = null)
	{
		static::$m_oFileLog = $oFileLog;
		static::$m_oMockMetaModelConfig = $oMetaModelConfig;
	}

	public static function Error($sMessage, $sChannel = null, $aContext = array())
	{
		static::Log(self::LEVEL_ERROR, $sMessage, $sChannel, $aContext);
	}

	public static function Warning($sMessage, $sChannel = null, $aContext = array())
	{
		static::Log(self::LEVEL_WARNING, $sMessage, $sChannel, $aContext);
	}

	public static function Info($sMessage, $sChannel = null, $aContext = array())
	{
		static::Log(self::LEVEL_INFO, $sMessage, $sChannel, $aContext);
	}

	public static function Ok($sMessage, $sChannel = null, $aContext = array())
	{
		static::Log(self::LEVEL_OK, $sMessage, $sChannel, $aContext);
	}

	public static function Debug($sMessage, $sChannel = null, $aContext = array())
	{
		static::Log(self::LEVEL_DEBUG, $sMessage, $sChannel, $aContext);
	}

	public static function Trace($sMessage, $sChannel = null, $aContext = array())
	{
		static::Log(self::LEVEL_TRACE, $sMessage, $sChannel, $aContext);
	}

	/**
	 * @throws \ConfigException if log wrongly configured
	 */
	public static function Log($sLevel, $sMessage, $sChannel = null, $aContext = array())
	{
		if (!isset(self::$aLevelsPriority[$sLevel])) {
			IssueLog::Error("invalid log level '{$sLevel}'");

			return;
		}

		if (is_null($sChannel)) {
			$sChannel = static::CHANNEL_DEFAULT;
		}

		static::WriteLog($sLevel, $sMessage, $sChannel, $aContext);
	}

	/**
	 * @throws \ConfigException
	 */
	protected static function WriteLog(string $sLevel, string $sMessage, ?string $sChannel = null, ?array $aContext = array()): void
	{
		if (
			(null !== static::$m_oFileLog)
			&& static::IsLogLevelEnabled($sLevel, $sChannel, static::ENUM_CONFIG_PARAM_FILE)
		) {
			static::$m_oFileLog->$sLevel($sMessage, $sChannel, $aContext);
		}

		if (static::IsLogLevelEnabled($sLevel, $sChannel, static::ENUM_CONFIG_PARAM_DB)) {
			self::WriteToDb($sMessage, $sChannel, $aContext);
		}
	}

	public static function GetUserInfo(): ?string
	{
		$oConnectedUser = UserRights::GetUserObject();
		if (is_null($oConnectedUser)) {
			return '';
		}

		return $oConnectedUser->GetKey();
	}

	/**
	 * @throws \ConfigException if log wrongly configured
	 * @uses GetMinLogLevel
	 * @since 3.0.0 N°3731
	 */
	final public static function IsLogLevelEnabled(string $sLevel, string $sChannel, string $sConfigKey = self::ENUM_CONFIG_PARAM_FILE): bool
	{
		$sMinLogLevel = self::GetMinLogLevel($sChannel, $sConfigKey);

		// the is_bool call is to remove a IDE O:) warning as $sMinLogLevel is typed as string
		if ((is_bool($sMinLogLevel) && ($sMinLogLevel === false)) || $sMinLogLevel === 'false') {
			return false;
		}
		if (!is_string($sMinLogLevel)) {
			return false;
		}

		if (!isset(self::$aLevelsPriority[$sMinLogLevel])) {
			throw new ConfigException("invalid configuration for log_level '{$sMinLogLevel}' is not within the list: ".implode(',', array_keys(self::$aLevelsPriority)));
		} elseif (self::$aLevelsPriority[$sLevel] < self::$aLevelsPriority[$sMinLogLevel]) {
			return false;
		}

		return true;
	}

	/**
	 * @param string $sChannel
	 * @param string $sConfigKey
	 *
	 * @return string one of the LEVEL_* const value : the one configured it if exists, otherwise default log level for this channel
	 *       Config can be set :
	 *          * globally : `'log_level_min' => LogAPI::LEVEL_TRACE,`
	 *          * per channel :
	 *            ```
	 *            'log_level_min' => [
	 *                ''            => LogAPI::LEVEL_ERROR, // default log level for channels not listed below
	 *                'InlineImage' => LogAPI::LEVEL_TRACE,
	 *                'UserRequest' => LogAPI::LEVEL_TRACE
	 *            ],
	 *            ```
	 *
	 * @uses \LogAPI::GetConfig()
	 * @uses `log_level_min` config parameter
	 * @uses `log_level_min.write_to_db` config parameter
	 * @uses \LogAPI::GetLevelDefault
	 *
	 * @link https://www.itophub.io/wiki/page?id=3_0_0%3Aadmin%3Alog iTop log reference
	 */
	protected static function GetMinLogLevel($sChannel, $sConfigKey = self::ENUM_CONFIG_PARAM_FILE)
	{
		$sLogLevelMin = static::GetLogConfig($sConfigKey);

		$sConfiguredLevelForChannel = static::GetMinLogLevelFromChannel($sLogLevelMin, $sChannel, $sConfigKey);
		if (!is_null($sConfiguredLevelForChannel)) {
			return $sConfiguredLevelForChannel;
		}

		return static::GetMinLogLevelFromDefault($sLogLevelMin, $sChannel, $sConfigKey);
	}

	final protected static function GetLogConfig($sConfigKey)
	{
		$oConfig = static::GetConfig();
		if (!$oConfig instanceof Config) {
			return static::GetLevelDefault($sConfigKey);
		}

		return $oConfig->Get($sConfigKey);
	}

	/**
	 * @param string|array $sLogLevelMin log config parameter value
	 * @param string $sChannel
	 * @param string $sConfigKey config option key
	 *
	 * @return string|null null if not defined
	 */
	protected static function GetMinLogLevelFromChannel($sLogLevelMin, $sChannel, $sConfigKey)
	{
		if (empty($sLogLevelMin)) {
			return static::GetLevelDefault($sConfigKey);
		}

		if (!is_array($sLogLevelMin)) {
			return $sLogLevelMin;
		}

		if (isset($sLogLevelMin[$sChannel])) {
			return $sLogLevelMin[$sChannel];
		}

		return null;
	}

	protected static function GetMinLogLevelFromDefault($sLogLevelMin, $sChannel, $sConfigKey)
	{
		if (isset($sLogLevelMin[static::CHANNEL_DEFAULT])) {
			return $sLogLevelMin[static::CHANNEL_DEFAULT];
		}

		// Even though the *self*::CHANNEL_DEFAULT is set to '' in the current class (LogAPI), the test below is necessary as the CHANNEL_DEFAULT constant can be (and is!) overloaded in children classes, don't remove this test to factorize it with the previous one.
		if (isset($sLogLevelMin[''])) {
			return $sLogLevelMin[''];
		}

		return static::GetLevelDefault($sConfigKey);
	}

	protected static function WriteToDb(string $sMessage, string $sChannel, array $aContext): void
	{
		if (false === MetaModel::IsLogEnabledIssue()) {
			return;
		}
		if (false === MetaModel::IsValidClass('EventIssue')) {
			return;
		}

		// Protect against reentrance
		static $bWriteToDbReentrance;
		if ($bWriteToDbReentrance === true) {
			return;
		}
		$bWriteToDbReentrance = true;

		try {
			self::$oLastEventIssue = static::GetEventIssue($sMessage, $sChannel, $aContext);
			self::$oLastEventIssue->DBInsertNoReload();
		}
		catch (Exception $e) {
			// calling low level methods : if we would call Error() for example we would try to write to DB again...
			static::$m_oFileLog->Error('Failed to log issue into the DB', LogChannels::CORE, [
				'exception message' => $e->getMessage(),
				'exception stack'   => $e->getTraceAsString(),
			]);
		}
		finally {
			$bWriteToDbReentrance = false;
		}
	}

	/**
	 * @throws \CoreException
	 * @throws \CoreUnexpectedValue
	 * @throws \OQLException
	 */
	protected static function GetEventIssue(string $sMessage, string $sChannel, array $aContext): EventIssue
	{
		$sDate = date('Y-m-d H:i:s');
		$aStack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 5);
		$sCurrentCallStack = var_export($aStack, true);

		$oEventIssue = new EventIssue();
		$oEventIssue->Set('issue', $sMessage);
		$oEventIssue->Set('message', $sMessage);
		$oEventIssue->Set('date', $sDate);
		$oEventIssue->Set('userinfo', static::GetUserInfo());
		$oEventIssue->Set('callstack', $sCurrentCallStack);
		$oEventIssue->Set('data', $aContext);

		return $oEventIssue;
	}

	/**
	 * **Warning** : during \MFCompiler::Compile the config will be partial, so when logging in this method you won't get the proper log config !
	 * See N°4345
	 *
	 * @uses m_oMockMetaModelConfig if defined
	 * @uses \MetaModel::GetConfig()
	 */
	protected static function GetConfig(): ?Config
	{
		return static::$m_oMockMetaModelConfig ?? \utils::GetConfig();
	}

	/**
	 * A method to override if default log level needs to be computed. Otherwise, simply override the corresponding constants
	 *
	 * @used-by GetMinLogLevel
	 *
	 * @param string $sConfigKey config key used for log
	 *
	 * @return string|bool if false, then disable log for any level
	 *
	 * @uses    \LogAPI::LEVEL_DEFAULT
	 * @uses    \LogAPI::LEVEL_DEFAULT_DB
	 *
	 * @since 3.0.0 N°3731 Method creation
	 * @since 3.0.0 N°4261 add specific default level for DB write
	 */
	protected static function GetLevelDefault(string $sConfigKey)
	{
		switch ($sConfigKey) {
			case static::ENUM_CONFIG_PARAM_DB:
				return static::LEVEL_DEFAULT_DB;
			case static::ENUM_CONFIG_PARAM_FILE:
			default:
				return static::LEVEL_DEFAULT;
		}
	}
}

class SetupLog extends LogAPI
{
	const CHANNEL_DEFAULT = 'SetupLog';
	/**
	 * @inheritDoc
	 *
	 * As this object is used during setup, without any conf file available, customizing the level can be done by changing this constant !
	 */
	const LEVEL_DEFAULT = self::LEVEL_INFO;

	protected static $m_oFileLog = null;

	/**
	 * In the setup there is no user logged...
	 *
	 * @return string|null
	 */
	public static function GetUserInfo(): ?string
	{
		return 'SETUP';
	}
}

class IssueLog extends LogAPI
{
	const CHANNEL_DEFAULT = 'IssueLog';

	protected static $m_oFileLog = null;
}

class ToolsLog extends LogAPI
{
	const CHANNEL_DEFAULT = 'ToolsLog';

	protected static $m_oFileLog = null;
}

/**
 * @see \CMDBSource::LogDeadLock()
 * @since 2.7.1 PR #139
 *
 * @link https://dev.mysql.com/doc/refman/5.7/en/innodb-deadlocks.html
 */
class DeadLockLog extends LogAPI
{
	const CHANNEL_WAIT_TIMEOUT = 'Deadlock-WaitTimeout';
	const CHANNEL_DEADLOCK_FOUND = 'Deadlock-Found';
	const CHANNEL_DEFAULT = self::CHANNEL_WAIT_TIMEOUT;

	/** @var \FileLog we want our own instance ! */
	protected static $m_oFileLog = null;

	public static function Enable($sTargetFile = null)
	{
		if (empty($sTargetFile))
		{
			$sTargetFile = APPROOT.'log/deadlocks.log';
		}
		parent::Enable($sTargetFile);
	}

	/** @noinspection PhpUnreachableStatementInspection we want to keep the break statements to keep clarity and avoid errors */
	private static function GetChannelFromMysqlErrorNo($iMysqlErrorNo)
	{
		switch ($iMysqlErrorNo)
		{
			case CMDBSource::MYSQL_ERRNO_WAIT_TIMEOUT:
				return self::CHANNEL_WAIT_TIMEOUT;
				break;
			case CMDBSource::MYSQL_ERRNO_DEADLOCK:
				return self::CHANNEL_DEADLOCK_FOUND;
				break;
			default:
				return self::CHANNEL_DEFAULT;
				break;
		}
	}

	/**
	 * @param string $sLevel
	 * @param string $sMessage
	 * @param int $iMysqlErrorNumber will be converted to channel using {@link GetChannelFromMysqlErrorNo}
	 * @param array $aContext
	 *
	 * @throws \Exception
	 * @noinspection PhpParameterNameChangedDuringInheritanceInspection
	 *
	 * @since 2.7.1 method creation
	 * @since 2.7.5 3.0.0 rename param names and fix phpdoc (thanks Hipska !)
	 */
	public static function Log($sLevel, $sMessage, $iMysqlErrorNumber = null, $aContext = array())
	{
		$sChannel = self::GetChannelFromMysqlErrorNo($iMysqlErrorNumber);
		parent::Log($sLevel, $sMessage, $sChannel, $aContext);
	}
}


/**
 * Starting with the WARNING level we will log in a dedicated file (/log/deprecated-calls.log) :
 * - iTop deprecated files or code
 * - protected trigger_error calls with E_DEPRECATED or E_USER_DEPRECATED
 *
 * For the last category, if {@see utils::IsDevelopmentEnvironment()} is true we will do a trigger_error()
 *
 * @since 3.0.0 N°3731 first implementation
 * @link https://www.itophub.io/wiki/page?id=latest:admin:log:channels#deprecated_calls channel used
 */
class DeprecatedCallsLog extends LogAPI
{
	/**
	 * @var string
	 * @since 3.2.0 N°4897
	 */
	public const ENUM_CHANNEL_PHP_API = 'deprecated-php-api';
	public const ENUM_CHANNEL_PHP_METHOD = 'deprecated-php-method';
	/**
	 * @var string
	 * @since 3.1.0
	 */
	public const ENUM_CHANNEL_PHP_ENDPOINT = 'deprecated-php-endpoint';
	public const ENUM_CHANNEL_PHP_LIBMETHOD = 'deprecated-php-libmethod';
	public const ENUM_CHANNEL_FILE = 'deprecated-file';
	public const CHANNEL_DEFAULT = self::ENUM_CHANNEL_PHP_METHOD;

	/** @var string Warning this constant won't be used directly ! To see the real default level check {@see GetLevelDefault()} */
	public const LEVEL_DEFAULT = self::LEVEL_ERROR;

	/** @var \FileLog we want our own instance ! */
	protected static $m_oFileLog = null;

	/**
	 * Indirection to {@see \LogAPI::IsLogLevelEnabled()} that is handling possible {@see ConfigException}
	 *
	 * @param string $sLevel
	 * @param string $sChannel
	 *
	 * @return bool if exception occurs, then returns false
	 *
	 * @uses \LogAPI::IsLogLevelEnabled()
	 */
	protected static function IsLogLevelEnabledSafe($sLevel, $sChannel): bool
	{
		try {
			$bIsLogLevelEnabled = static::IsLogLevelEnabled(self::LEVEL_WARNING, self::ENUM_CHANNEL_PHP_LIBMETHOD);
		}
		catch (ConfigException $e) {
			$bIsLogLevelEnabled = false;
		}

		return $bIsLogLevelEnabled;
	}

	/**
	 * @param string|null $sTargetFile
	 *
	 * @uses \set_error_handler() to catch deprecated notices
	 *
	 * @since 3.0.0 N°3002 logs deprecated notices in called code
	 * @since 3.0.4 N°6274 do not set handler when in PHPUnit context (otherwise PHP notices won't be caught)
	 */
	public static function Enable($sTargetFile = null): void {
		if (empty($sTargetFile)) {
			$sTargetFile = APPROOT.'log/deprecated-calls.log';
		}
		parent::Enable($sTargetFile);

		if (
			(
				(false === defined(ITOP_PHPUNIT_RUNNING_CONSTANT_NAME))
				|| (defined(ITOP_PHPUNIT_RUNNING_CONSTANT_NAME) && (constant(ITOP_PHPUNIT_RUNNING_CONSTANT_NAME) !== true))
			)
			&& static::IsLogLevelEnabledSafe(self::LEVEL_WARNING, self::ENUM_CHANNEL_PHP_LIBMETHOD)
		) {
			IssueLog::Trace('Setting '.static::class.' error handler to catch DEPRECATED', static::ENUM_CHANNEL_PHP_LIBMETHOD);
			set_error_handler([static::class, 'DeprecatedNoticesErrorHandler'], E_DEPRECATED | E_USER_DEPRECATED);
		}
	}

	/**
	 * This will catch a message for all E_DEPRECATED and E_USER_DEPRECATED errors.
	 * This handler is set in {@see DeprecatedCallsLog::Enable}
	 *
	 * @param int $errno
	 * @param string $errstr
	 * @param string $errfile
	 * @param int $errline
	 *
	 * @return bool
	 * @since 3.0.0 N°3002
	 * @noinspection SpellCheckingInspection
	 */
	public static function DeprecatedNoticesErrorHandler(int $errno, string $errstr, string $errfile, int $errline): bool
	{
		if (
			(\E_USER_DEPRECATED !== $errno)
			&& (\E_DEPRECATED !== $errno)
		) {
			return false;
		}

		$aStack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4);
		if (isset($aStack[2]['function']) && ($aStack[2]['function'] == 'ForwardToTriggerError')) {
			// Let the notice bubble up
			return false;
		}

		if (false === static::IsLogLevelEnabledSafe(self::LEVEL_WARNING, self::ENUM_CHANNEL_PHP_LIBMETHOD)) {
			// returns true so that nothing is thrown!
			return true;
		}

		$aStack = static::StripCallStack($aStack);
		$sMessage = "$errstr, called from ".static::SummarizeCallStack($aStack);

		static::Warning($sMessage, self::ENUM_CHANNEL_PHP_LIBMETHOD);
		static::ForwardToTriggerError($sMessage);

		return true;
	}

	/**
	 * Override so that :
	 * - if we are in dev mode ({@see \utils::IsDevelopmentEnvironment()}), the level for file will be DEBUG
	 * - else call parent method
	 *
	 * In other words, when in dev mode all deprecated calls will be logged to file
	 */
	protected static function GetLevelDefault(string $sConfigKey)
	{
		if ($sConfigKey === self::ENUM_CONFIG_PARAM_DB) {
			return parent::GetLevelDefault($sConfigKey);
		}

		if (utils::IsDevelopmentEnvironment()) {
			return static::LEVEL_DEBUG;
		}

		return parent::GetLevelDefault($sConfigKey);
	}

	/**
	 * Call this helper at the beginning of a deprecated file (in its global scope)
	 *
	 * @since 3.0.1 3.1.0 N°4725 silently handles ConfigException
	 * @since 3.0.4 3.1.0 N°4725 remove forgotten throw PHPDoc annotation
	 *
	 * @link https://www.php.net/debug_backtrace
	 * @uses \debug_backtrace()
	 */
	public static function NotifyDeprecatedFile(?string $sAdditionalMessage = null): void
	{
		try {
			if (!static::IsLogLevelEnabled(self::LEVEL_WARNING, self::ENUM_CHANNEL_FILE)) {
				return;
			}
		}
		catch (ConfigException $e) {
			return;
		}

		$aStack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2);
		$sDeprecatedFile = $aStack[0]['file'];
		if (array_key_exists(1, $aStack)) {
			$sCallerFile = $aStack[1]['file'];
			$sCallerLine = $aStack[1]['line'];
		} else {
			$sCallerFile = 'N/A';
			$sCallerLine = 'N/A';
		}

		$sMessage = "{$sCallerFile} L{$sCallerLine} including/requiring {$sDeprecatedFile}";

		if (!is_null($sAdditionalMessage)) {
			$sMessage .= ' : '.$sAdditionalMessage;
		}

		static::Warning($sMessage, static::ENUM_CHANNEL_FILE);
		static::ForwardToTriggerError($sMessage);
	}

	/**
	 * Call this helper when calling a deprecated extension method
	 *
	 * @param string $sImplementationClass Class implementing the deprecated API
	 * @param string $sDeprecatedApi Class name of the deprecated API
	 * @param string $sDeprecatedMethod Method name of the deprecated API
	 * @param string|null $sAdditionalMessage Additional message, mostly used to explain what API to use instead
	 *
	 * @return void
	 * @since 3.2.0 N°4897
	 */
	public static function NotifyDeprecatedPhpApi(string $sImplementationClass, string $sDeprecatedApi, string $sDeprecatedMethod, ?string $sAdditionalMessage = null): void
	{
		try {
			if (!static::IsLogLevelEnabled(self::LEVEL_WARNING, self::ENUM_CHANNEL_PHP_API)) {
				return;
			}
		}
		catch (ConfigException $oException) {
			return;
		}

		$sMessage = "Implementation of {$sDeprecatedApi}::{$sDeprecatedMethod}() in class {$sImplementationClass}";

		if (!is_null($sAdditionalMessage)) {
			$sMessage .= " : $sAdditionalMessage";
		}

		static::Warning($sMessage, self::ENUM_CHANNEL_PHP_API);
		static::ForwardToTriggerError($sMessage);
	}

	/**
	 * Call this helper within deprecated methods
	 *
	 * @param string|null $sAdditionalMessage
	 *
	 * @link https://www.php.net/debug_backtrace
	 * @uses \debug_backtrace()
	 */
	public static function NotifyDeprecatedPhpMethod(?string $sAdditionalMessage = null): void
	{
		try {
			if (!static::IsLogLevelEnabled(self::LEVEL_WARNING, self::ENUM_CHANNEL_PHP_METHOD)) {
				return;
			}
		}
		catch (ConfigException $e) {
			return;
		}

		$aStack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);

		if (isset($aStack[1]['class'])) {
			$sFunctionDesc = $aStack[1]['class'].$aStack[1]['type'].$aStack[1]['function'];
		}
		else {
			$sFunctionDesc = $aStack[1]['function'];
		}

		$sMessage = "Function $sFunctionDesc() is deprecated";
		if (!is_null($sAdditionalMessage)) {
			$sMessage .= ': '.$sAdditionalMessage;
		}

		$sMessage .= '. Caller: '.self::SummarizeCallStack(array_slice($aStack, 1));


		static::Warning($sMessage, self::ENUM_CHANNEL_PHP_METHOD);
		static::ForwardToTriggerError($sMessage);
	}

	/**
	 * @param string|null $sAdditionalMessage
	 * @since 3.1.0
	 */
	public static function NotifyDeprecatedPhpEndpoint(?string $sAdditionalMessage = null): void
	{
		try {
			if (!static::IsLogLevelEnabled(self::LEVEL_WARNING, self::ENUM_CHANNEL_PHP_ENDPOINT)) {
				return;
			}
		}
		catch (ConfigException $e) {
			return;
		}

		$aStack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
		$iStackDeprecatedMethodLevel = 0; // level 0 = current method, level 1 = method containing the `NotifyDeprecatedPhpMethod` call
		$sDeprecatedUrl = $_SERVER['REQUEST_URI'];
		$sCallerFile = $aStack[$iStackDeprecatedMethodLevel]['file'];
		$sCallerLine = $aStack[$iStackDeprecatedMethodLevel]['line'];
		$sMessage = "Call to endpoint {$sDeprecatedUrl} in {$sCallerFile}#L{$sCallerLine}";

		if (!is_null($sAdditionalMessage)) {
			$sMessage .= ' : '.$sAdditionalMessage;
		}

		static::Warning($sMessage, self::ENUM_CHANNEL_PHP_ENDPOINT);
		static::ForwardToTriggerError($sMessage);
	}

	public static function Log($sLevel, $sMessage, $sChannel = null, $aContext = array()): void
	{
		try {
			parent::Log($sLevel, $sMessage, $sChannel, $aContext);
		}
		catch (ConfigException $e) {
			// nothing much we can do... and we don't want to crash the caller !
		}
	}

	/**
	 * Strips some elements from the top of the call stack to skip calls that are not relevant to report the deprecated call
	 * @param array $aCallStack Call stack as returned by {@see debug_backtrace()}
	 */
	protected static function StripCallStack($aCallStack): array
	{
		if (!isset($aCallStack[0]['line'])) {
			$aCallStack = array_slice($aCallStack, 1);
		}
		if (isset($aCallStack[1]['function']) && $aCallStack[1]['function'] === 'trigger_deprecation') {
			$aCallStack = array_slice($aCallStack, 1);
		}

		return $aCallStack;
	}

	protected static function SummarizeCallStack($aCallStack, $bRecurse = true)
	{
		if (count($aCallStack) == 0) {
			return null;
		}
		$sFileLine = $aCallStack[0]['file'].'#'.$aCallStack[0]['line'];
		$sSummary = $sFileLine;

		// If possible and meaningful, add the class and method
		if (isset($aCallStack[1]['class'])) {
			$sSummary = $aCallStack[1]['class'].$aCallStack[1]['type'].$aCallStack[1]['function']." ($sFileLine)";
		}
		elseif (isset($aCallStack[1]['function'])) {
			if (in_array($aCallStack[1]['function'], ['include', 'require', 'include_once', 'require_once'])) {
				// No need to show the generic mechanism of inclusion
				$bRecurse = false;
			}
			else {
				$sSummary = $aCallStack[1]['function']." ($sFileLine)";
			}
		}

		if ($bRecurse) {
			$sUpperSummary = static::SummarizeCallStack(array_slice($aCallStack, 1), false);
			if (!is_null($sUpperSummary)) {
				$sSummary .= ', itself called from '.$sUpperSummary;
			}
		}

		return $sSummary;
	}

	private static function ForwardToTriggerError(string $sMessage): void
	{
		if (true === utils::IsDevelopmentEnvironment()) {
			trigger_error($sMessage, E_USER_DEPRECATED);
		}
	}
}


class LogFileRotationProcess implements iScheduledProcess
{
	/**
	 * Cannot get this list from anywhere as log file name is provided by the caller using LogAPI::Enable
	 *
	 * @var string[]
	 */
	const LOGFILES_TO_ROTATE = array(
		'setup.log',
		'error.log',
		'tools.log',
		'itop-fence.log',
	);

	/**
	 * @inheritDoc
	 */
	public function Process($iUnixTimeLimit)
	{
		$sLogFileNameBuilder = $this->GetLogFileNameBuilderClassName();

		foreach (self::LOGFILES_TO_ROTATE as $sLogFileName) {
			$sLogFileFullPath = APPROOT
				.DIRECTORY_SEPARATOR.'log'
				.DIRECTORY_SEPARATOR.$sLogFileName;

			/** @var \RotatingLogFileNameBuilder $oLogFileNameBuilder */
			$oLogFileNameBuilder = new $sLogFileNameBuilder($sLogFileFullPath);
			$oLogFileNameBuilder->ResetLastModifiedDateForFile();
			$oLogFileNameBuilder->CheckAndRotateLogFile();
		}

		// Purge logs if purge enabled
		if (MetaModel::GetConfig()->Get(LogAPI::ENUM_CONFIG_PARAM_PURGE_ENABLED)) {
			$this->PurgeLogs();
		}
	}

	/**
	 * PurgeLogs.
	 *
	 * Purge test last modification time of file in log folder and delete
	 * files that haven't modifications since {@see \LogAPI::ENUM_CONFIG_PARAM_PURGE_MAX_KEEP_DAYS}
	 *
	 * @return array process feedback
	 * @since 3.1.0
	 */
	public function PurgeLogs(): array
	{
		// result
		$aFilesResult = array();

		// Max keep days
		$iMaxDays = MetaModel::GetConfig()->Get(LogAPI::ENUM_CONFIG_PARAM_PURGE_MAX_KEEP_DAYS);

		// Files iterator (*.*)
		$oIterator = new \GlobIterator(APPROOT.'log'.DIRECTORY_SEPARATOR.'*.*');
		$aLogFiles = iterator_to_array($oIterator);

		// Reference date
		$oDateNow = new DateTime('now');

		// Iterate throw files...
		foreach ($aLogFiles as $oLogFile) {

			// File real path
			$sFileRealPath = $oLogFile->getRealPath();

			// Check file extension
			if(!in_array($oLogFile->getExtension(), ['log','sql','xml'])){
				continue;
		    }

			// Compute number of days since last modification
			$oDateFileLastModification = new DateTime();
			$oDateFileLastModification->setTimestamp($oLogFile->getMTime());
			$iDays = intval($oDateFileLastModification->diff($oDateNow)->format('%a'));

			// File process status
			$aFileResult = [
				'file_name'                    => $sFileRealPath,
				'days_since_last_modification' => $iDays,
				'deleted'                      => false,
				'error'                        => null,
			];

			// Delete file older than max last modified in days
			if ($iDays > $iMaxDays) {

				// unlink file
				if (!is_writable($sFileRealPath)) {
					$aFileResult['error'] = Dict::S('itop-log-mgmt:UI:Error:file_read_only');
				} // unlink OK
				else if (unlink($sFileRealPath)) {
					$aFileResult['deleted'] = true;
				} // unlink KO
				else {
					$aFileResult['error'] = Dict::S('itop-log-mgmt:UI:Error:unknown_error');
				}

			}

			// append result
			$aFilesResult[] = $aFileResult;
		}

		return $aFilesResult;
	}

	/**
	 * @inheritDoc
	 */
	public function GetNextOccurrence()
	{
		try {
			$sLogFileNameBuilder = $this->GetLogFileNameBuilderClassName();
		}
		catch (ProcessException $e) {
			return new DateTime('3000-01-01');
		}

		/** @var \RotatingLogFileNameBuilder $oLogFileNameBuilder */
		$oLogFileNameBuilder = new $sLogFileNameBuilder();
		return $oLogFileNameBuilder->GetCronProcessNextOccurrence(new DateTime());
	}

	/**
	 * @return string RotatingLogFileNameBuilder implementation configured
	 * @throws \ProcessException if the class is invalid
	 */
	private function GetLogFileNameBuilderClassName()
	{
		$sLogFileNameBuilder = MetaModel::GetConfig()->Get('log_filename_builder_impl');
		if (is_a($sLogFileNameBuilder, RotatingLogFileNameBuilder::class, true))
		{
			return $sLogFileNameBuilder;
		}

		throw new ProcessException(self::class.' : The configured filename builder is invalid (log_filename_builder_impl="'.$sLogFileNameBuilder.'")');
	}
}

/**
 * Log exceptions using dedicated API and logic.
 *
 * Please use {@see ExceptionLog::LogException()} to log exceptions
 *
 * @since 3.0.0 N°4261 class creation to ease logging when an exception occurs
 */
class ExceptionLog extends LogAPI
{
	public const CHANNEL_DEFAULT = 'Exception';
	public const CONTEXT_EXCEPTION = '__exception';

	protected static $m_oFileLog = null;

	/**
	 * This method should be used to write logs.
	 *
	 * As it encapsulate the operations performed using the Exception, you should prefer it to the standard API inherited from LogApi `ExceptionLog::Error($oException->getMessage(), get_class($oException), ['__exception' => $oException]);`
	 * The parameter order is not standard, but in our use case, the resulting API is way more convenient this way !
	 */
	public static function LogException(Throwable $oException, $aContext = array(), $sLevel = self::LEVEL_ERROR): void
	{
		if (!isset(self::$aLevelsPriority[$sLevel])) {
			IssueLog::Error("invalid log level '{$sLevel}'");

			return;
		}

		$sExceptionClass = get_class($oException);

		$aDefaultValues = [
			self::CONTEXT_EXCEPTION => $oException,
			'exception class' => $sExceptionClass,
			'file' => $oException->getFile(),
			'line' => $oException->getLine(),
		];
		$aContext = array_merge($aDefaultValues, $aContext);

		parent::Log($sLevel, $oException->getMessage(), $sExceptionClass, $aContext);
	}

	/** @noinspection PhpUnhandledExceptionInspection */
	public static function Log($sLevel, $sMessage, $sChannel = null, $aContext = array())
	{
		throw new ApplicationException('Do not call this directly, prefer using ExceptionLog::LogException() instead');
	}

	/** @noinspection PhpParameterNameChangedDuringInheritanceInspection */
	protected static function WriteLog(string $sLevel, string $sMessage, ?string $sExceptionClass = null, ?array $aContext = array()): void
	{
		if (
			(null !== static::$m_oFileLog)
			&& static::IsLogLevelEnabled($sLevel, $sExceptionClass, static::ENUM_CONFIG_PARAM_FILE)
		) {
			$sExceptionClassConfiguredForFile = static::ExceptionClassFromHierarchy($sExceptionClass, static::ENUM_CONFIG_PARAM_FILE);
			if (null === $sExceptionClassConfiguredForFile) {
				$sExceptionClassConfiguredForFile = $sExceptionClass;
			}

			// clearing the Exception object as it is too verbose to write to a file !
			$aContextForFile = array_diff_key($aContext, [self::CONTEXT_EXCEPTION => null]);

			static::$m_oFileLog->$sLevel($sMessage, $sExceptionClassConfiguredForFile, $aContextForFile);
		}

		if (static::IsLogLevelEnabled($sLevel, $sExceptionClass, static::ENUM_CONFIG_PARAM_DB)) {
			$sExceptionClassConfiguredForDb = static::ExceptionClassFromHierarchy($sExceptionClass, static::ENUM_CONFIG_PARAM_DB);
			if (null === $sExceptionClassConfiguredForDb) {
				$sExceptionClassConfiguredForDb = $sExceptionClass;
			}
			self::WriteToDb($sMessage, $sExceptionClassConfiguredForDb, $aContext);
		}
	}

	/**
	 * Will seek for the configuration based on the exception class, using {@see \ExceptionLog::ExceptionClassFromHierarchy()}
	 *
	 * @param string $sExceptionClass
	 * @param string $sConfigKey
	 *
	 * @return string
	 * @noinspection PhpParameterNameChangedDuringInheritanceInspection
	 */
	protected static function GetMinLogLevel($sExceptionClass, $sConfigKey = self::ENUM_CONFIG_PARAM_FILE)
	{
		$sLogLevelMin = static::GetLogConfig($sConfigKey);
		$sExceptionClassInConfig = static::ExceptionClassFromHierarchy($sExceptionClass, $sConfigKey);

		if (null !== $sExceptionClassInConfig) {
			return $sConfigKey[$sExceptionClassInConfig];
		}

		return static::GetMinLogLevelFromDefault($sLogLevelMin, $sExceptionClass, $sConfigKey);
	}

	/**
	 * Searching config first for the current exception class
	 * If not found we are seeking for config for all the parent classes
	 *
	 * That means if we are logging a UnknownClassOqlException, we will seek log config all the way the class hierarchy :
	 * 1. UnknownClassOqlException
	 * 2. OqlNormalizeException
	 * 3. OQLException
	 * 4. CoreException
	 * 5. Exception
	 *
	 * @param string $sExceptionClass
	 * @param string $sConfigKey
	 *
	 * @return string|null the current or parent class name defined in the config, otherwise null if no class of the hierarchy found in the config
	 */
	protected static function ExceptionClassFromHierarchy($sExceptionClass, $sConfigKey = self::ENUM_CONFIG_PARAM_FILE)
	{
		$sLogLevelMin = static::GetLogConfig($sConfigKey);

		if (false === is_array($sLogLevelMin)) {
			return null;
		}

		$sExceptionClassInHierarchy = $sExceptionClass;
		while ($sExceptionClassInHierarchy !== false) {
			$sConfiguredLevelForExceptionClass = static::GetMinLogLevelFromChannel($sLogLevelMin, $sExceptionClassInHierarchy, $sConfigKey);
			if (!is_null($sConfiguredLevelForExceptionClass)) {
				break;
			}

			$sExceptionClassInHierarchy = get_parent_class($sExceptionClassInHierarchy);
		}

		if ($sExceptionClassInHierarchy === false) {
			return null;
		}

		return $sExceptionClassInHierarchy;
	}

	protected static function GetEventIssue(string $sMessage, string $sChannel, array $aContext): EventIssue
	{
		$oEventIssue = parent::GetEventIssue($sMessage, $sChannel, $aContext);

		$oContextException = $aContext[self::CONTEXT_EXCEPTION];
		unset($aContext[self::CONTEXT_EXCEPTION]);

		$sIssue = ($oContextException instanceof CoreException) ? $oContextException->GetIssue() : 'PHP Exception';
		$sErrorStackTrace = ($oContextException instanceof CoreException) ? $oContextException->getFullStackTraceAsString() : $oContextException->getTraceAsString();
		$aContextData = ($oContextException instanceof CoreException) ? $oContextException->getContextData() : [];

		$oEventIssue->Set('issue', $sIssue);
		$oEventIssue->Set('message', $oContextException->getMessage());
		$oEventIssue->Set('callstack', $sErrorStackTrace);
		$oEventIssue->Set('data', array_merge($aContextData, $aContext));

		return $oEventIssue;
	}

	/**
	 * @inheritDoc
	 */
	public static function Enable($sTargetFile = null)
	{
		if (empty($sTargetFile)) {
			$sTargetFile = APPROOT.'log/error.log';
		}
		parent::Enable($sTargetFile);
	}

	/**
	 * @internal Used by the tests
	 */
	private static function GetLastEventIssue()
	{
		$oRet = self::$oLastEventIssue;
		self::$oLastEventIssue = null;
		return $oRet;
	}
}
