Imports System.IO
Imports System.Reflection
Imports NLog
Imports NLog.Config
Imports NLog.Targets

''' <module>LogConfig</module>
''' <version>0.0.1.0</version>
''' <date>02.10.2018</date>
''' <summary>
''' Module that writes file-logs to different locations:
''' local application data, the current directory or a custom path.
''' Files and directories will be automatically created.
''' </summary>
''' <dependencies>
''' NLog, >= 4.5.8
''' </dependencies>
''' <example>
''' Imports DigitalData.Modules.Logging
''' 
''' Class FooProgram
'''     Private Logger as Logger
'''     Private LogConfig as LogConfig
'''                 
'''     Public Sub New()
'''         LogConfig = new LogConfig(args)
'''         Logger = LogConfig.GetLogger()
'''     End Sub
'''                     
'''     Public Sub Bar()
'''         Logger.Info("Baz")
'''     End Sub
''' End Class
'''                 
''' Class FooLib
'''     Private Logger as NLog.Logger
'''                 
'''     Public Sub New(LogConfig as LogConfig)
'''         Logger = LogConfig.GetLogger()
'''     End Sub
'''                     
'''     Public Sub Bar()
'''         Logger.Info("Baz")
'''     End Sub
''' End Class
''' </example>
''' <remarks>
''' If logpath can not be written to, falls back to temp folder as defined in:
''' https://docs.microsoft.com/de-de/dotnet/api/system.io.path.gettemppath?view=netframework-4.7.2
'''                 
''' If used in a service, LogPath must be set to CustomPath, otherwise the Log will be written to System32!
'''                 
''' For NLog Troubleshooting, set the following Environment variables to write the NLog internal Log:               
''' - NLOG_INTERNAL_LOG_LEVEL: Debug
''' - NLOG_INTERNAL_LOG_FILE: ex. C:\Temp\Nlog_Internal.log
''' </remarks>
Public Class LogConfig
#Region "Private Properties"
    Private Const KEEP_FILES_OPEN As Boolean = False
    ' MAX_ARCHIVES_FILES works like this (in version 4.5.8):
    ' 0 = keep ALL archives files
    ' 1 = only keep latest logfile and NO archive files
    ' n = keep n archive files
    Private Const MAX_ARCHIVE_FILES_DEFAULT As Integer = 0
    Private Const MAX_ARCHIVE_FILES_DEBUG_DETAIL As Integer = 0
    Private Const ARCHIVE_EVERY As FileArchivePeriod = FileArchivePeriod.Day

    Private Const FILE_NAME_FORMAT_DEFAULT As String = "${shortdate}-${var:product}${var:suffix}.log"
    Private Const FILE_NAME_FORMAT_DETAIL As String = "${shortdate}-${var:product}${var:suffix}-Detail.log"
    Private Const FILE_NAME_FORMAT_DEBUG As String = "${shortdate}-${var:product}${var:suffix}-Debug.log"
    Private Const FILE_NAME_FORMAT_ERROR As String = "${shortdate}-${var:product}${var:suffix}-Error.log"

    Private Const TARGET_DEFAULT As String = "defaultTarget"
    Private Const TARGET_ERROR_EX As String = "errorExceptionTarget"
    Private Const TARGET_ERROR As String = "errorTarget"
    Private Const TARGET_DETAIL As String = "detailTarget"
    Private Const TARGET_DEBUG As String = "debugTarget"
    Private Const TARGET_MEMORY As String = "memoryTarget"

    Private Const DATE_FORMAT_LONG As String = "${longdate}"
    Private Const DATE_FORMAT_DEFAULT As String = "${date:format=yyyy-MM-dd HH\:mm\:ss}"

    Private Const LOG_FORMAT_BASE As String = DATE_FORMAT_DEFAULT & "|${logger:shortName=True}|${level:uppercase=true}"
    Private Const LOG_FORMAT_BASE_LONG_DATE As String = DATE_FORMAT_LONG & "|${logger:shortName=True}|${level:uppercase=true}"

    Private Const LOG_FORMAT_DEFAULT As String = LOG_FORMAT_BASE & " >> ${message}"
    Private Const LOG_FORMAT_EXCEPTION As String = LOG_FORMAT_BASE & " >> ${exception:format=Message}${newline}${exception:format=StackTrace}"
    Private Const LOG_FORMAT_DEBUG As String = LOG_FORMAT_BASE_LONG_DATE & " >> ${message}"
    Private Const LOG_FORMAT_MEMORY As String = LOG_FORMAT_BASE_LONG_DATE & " >> ${message}${newline}${exception:format=Message}${newline}${exception:format=StackTrace}"

    Private Const FILE_NAME_ACCESS_TEST = "accessTest.txt"
    Private Const FOLDER_NAME_LOG = "Log"

    Private ReadOnly failSafePath As String = Path.GetTempPath()
    Private ReadOnly basePath As String = failSafePath

    Private config As LoggingConfiguration
    Private isDebug As Boolean = False
#End Region
#Region "Public Properties"
    Public Enum PathType As Integer
        AppData = 0
        CurrentDirectory = 1
        CustomPath = 2
        Temp = 3
    End Enum

    ''' <summary>
    ''' Returns the NLog.LogFactory object that is used to create Loggers
    ''' </summary>
    ''' <returns>LogFactory object</returns>
    Public ReadOnly Property LogFactory As LogFactory

    ''' <summary>
    ''' Returns the path to the current default logfile
    ''' </summary>
    ''' <returns>Filepath to the logfile</returns>
    Public ReadOnly Property LogFile As String

    ''' <summary>
    ''' Returns the path to the current log directory
    ''' </summary>
    ''' <returns>Directory path to the log directory</returns>
    Public ReadOnly Property LogDirectory As String

    ''' <summary>
    ''' Determines if a debug log will be written
    ''' </summary>
    ''' <returns>True, if debug log will be written. False otherwise.</returns>
    Public Property Debug As Boolean
        Get
            Return isDebug
        End Get
        Set(isDebug As Boolean)
            Me.isDebug = isDebug
            'GetLogger().Debug("=> Debug is now {0}", isDebug)
            ReloadConfig(isDebug)
        End Set
    End Property

    ''' <summary>
    ''' Returns Logs in Memory as List(Of String) if Debug is enabled
    ''' Returns an empty list if debug is disabled
    ''' </summary>
    ''' <returns>A list of log messages</returns>
    Public ReadOnly Property Logs As List(Of String)
        Get
            Dim oTarget = config.FindTargetByName(Of MemoryTarget)(TARGET_MEMORY)
            Return oTarget?.Logs.ToList()
        End Get
    End Property

    Public ReadOnly Property NLogConfig As LoggingConfiguration
        Get
            Return config
        End Get
    End Property

#End Region

    ''' <summary>
    ''' Initializes a new LogConfig object with a logpath and optinally a filename-suffix.
    ''' </summary>
    ''' <param name="LogPath">The basepath to write logs to. Can be AppData, CurrentDirectory or CustomPath.</param>
    ''' <param name="CustomLogPath">If `logPath` is set to custom, this defines the custom logPath.</param>
    ''' <param name="Suffix">If set to anything other than Nothing, extends the logfile name with this suffix.</param>
    ''' <param name="CompanyName">CompanyName is used to construct log-path in when LogPath is set to PathType:AppData</param>
    ''' <param name="ProductName">ProductName is used to construct log-path in when LogPath is set to PathType:AppData</param>
    Public Sub New(LogPath As PathType,
                  Optional CustomLogPath As String = Nothing,
                  Optional Suffix As String = Nothing,
                  Optional CompanyName As String = Nothing,
                  Optional ProductName As String = Nothing)

        If LogPath = PathType.AppData And (ProductName Is Nothing Or CompanyName Is Nothing) Then
            Throw New ArgumentException("Modules.Logging: PathType is AppData and either CompanyName or ProductName was not supplied!")
        End If

        If LogPath = PathType.CurrentDirectory Then
            Throw New ArgumentException("Modules.Logging: LogPath.CurrentDirectory is deprecated. Please use LogPath.CustomPath!")
        End If

        If LogPath = PathType.AppData Then
            Dim appDataDir = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)
            basePath = Path.Combine(appDataDir, CompanyName, ProductName, FOLDER_NAME_LOG)
        ElseIf LogPath = PathType.Temp Then
            basePath = failSafePath
        Else 'Custom Path
            basePath = CustomLogPath
        End If

        ' If directory does not exist, try to create it!
        If Not Directory.Exists(basePath) Then
            Try
                Directory.CreateDirectory(basePath)
            Catch ex As Exception
                ' If creation fails, use failSafe path
                basePath = failSafePath
            End Try
        End If

        ' Try to create a file in `basePath` to check write permissions
        Try
            Dim fileAccessPath = Path.Combine(basePath, FILE_NAME_ACCESS_TEST)
            Using fs As FileStream = File.Create(fileAccessPath)
                fs.WriteByte(0)
            End Using

            File.Delete(fileAccessPath)
        Catch ex As Exception
            ' If creation fails, use failSafe path
            basePath = failSafePath
        End Try

        ' Set the suffix to the given string if it exists
        Dim logFileSuffix As String = String.Empty

        If Suffix IsNot Nothing AndAlso Suffix.Count > 0 Then
            logFileSuffix = $"-{Suffix}"
        End If

        Dim oProductName As String = "Main"

        If ProductName IsNot Nothing Then
            oProductName = ProductName
        End If

        ' Create config object and initalize it
        config = GetConfig(oProductName, logFileSuffix)

        ' Save config
        LogFactory = New LogFactory With {
            .Configuration = config
        }

        ' Save log paths for files/directory
        LogDirectory = basePath
        LogFile = GetCurrentLogFilePath()
    End Sub

    Private Sub CheckMyApplication()
        Dim oAssembly = Assembly.GetEntryAssembly()
        Dim oMyApp = Nothing
        For Each oType As Type In oAssembly.DefinedTypes
            If oType.Name = "MyApplication" Then
                oMyApp = oType
                Exit For
            End If
        Next

        oMyApp.GetType().GetProperty("")
    End Sub

    ''' <summary>
    ''' Returns the Logger for the calling class
    ''' </summary>
    ''' <returns>An object of Logging.Logger</returns>
    <DebuggerStepThrough()>
    Public Function GetLogger() As Logger
        Dim oClassName As String = GetClassFullName()
        Return LogFactory.GetLogger(Of Logger)(oClassName)
    End Function

    ''' <summary>
    ''' Returns the Logger for a class specified by `ClassName`
    ''' </summary>
    ''' <param name="ClassName">The name of the class the logger belongs to</param>
    ''' <returns>An object of Logging.Logger</returns>
    <DebuggerStepThrough()>
    Public Function GetLogger(ClassName As String) As Logger
        Return LogFactory.GetLogger(Of Logger)(ClassName)
    End Function

    ''' <summary>
    ''' Clears the internal log
    ''' </summary>
    Public Sub ClearLogs()
        Dim oTarget = config.FindTargetByName(Of MemoryTarget)(TARGET_MEMORY)
        oTarget?.Logs.Clear()
    End Sub

    ''' <summary>
    ''' Gets the fully qualified name of the class invoking the calling method,
    ''' including the namespace but Not the assembly.  
    ''' </summary>
    ''' <returns>The fully qualified class name</returns>
    ''' <remarks>This method is very resource-intensive!</remarks>
    <DebuggerStepThrough()>
    Public Shared Function GetClassFullName() As String
        Dim oFramesToSkip As Integer = 2
        Dim oClassName As String = String.Empty
        Dim oStackTrace = Environment.StackTrace
        Dim oStackTraceLines = oStackTrace.Replace(vbCr, "").Split({vbLf}, StringSplitOptions.RemoveEmptyEntries)

        For i As Integer = 0 To oStackTraceLines.Length - 1
            Dim oCallingClassAndMethod = oStackTraceLines(i).Split({" ", "<>", "(", ")"}, StringSplitOptions.RemoveEmptyEntries)(1)
            Dim oMethodStartIndex As Integer = oCallingClassAndMethod.LastIndexOf(".", StringComparison.Ordinal)

            If oMethodStartIndex > 0 Then
                Dim oCallingClass = oCallingClassAndMethod.Substring(0, oMethodStartIndex)
                oClassName = oCallingClass.TrimEnd("."c)

                If Not oClassName.StartsWith("System.Environment") AndAlso oFramesToSkip <> 0 Then
                    i += oFramesToSkip - 1
                    oFramesToSkip = 0
                    Continue For
                End If

                If Not oClassName.StartsWith("System.") Then Exit For
            End If
        Next

        Return oClassName
    End Function

    ''' <summary>
    ''' Returns the initial log configuration
    ''' </summary>
    ''' <param name="productName">The chosen productname</param>
    ''' <param name="logFileSuffix">The chosen suffix</param>
    ''' <returns>A NLog.LoggingConfiguration object</returns>
    Private Function GetConfig(productName As String, logFileSuffix As String) As LoggingConfiguration
        config = New LoggingConfiguration()
        config.Variables("product") = productName
        config.Variables("suffix") = logFileSuffix

        ' Add default targets
        config.AddTarget(TARGET_ERROR_EX, GetErrorExceptionLogTarget(basePath))
        config.AddTarget(TARGET_ERROR, GetErrorLogTarget(basePath))
        config.AddTarget(TARGET_DEFAULT, GetDefaultLogTarget(basePath))
        config.AddTarget(TARGET_DEBUG, GetDebugLogTarget(basePath))
        config.AddTarget(TARGET_MEMORY, GetMemoryDebugTarget())

        ' Add default rules
        AddDefaultRules(config)

        Return config
    End Function

    ''' <summary>
    ''' Adds the default rules 
    ''' </summary>
    ''' <param name="config">A NLog.LoggingConfiguration object</param>
    Private Sub AddDefaultRules(ByRef config As LoggingConfiguration)
        config.AddRuleForOneLevel(LogLevel.Error, TARGET_ERROR_EX)
        config.AddRuleForOneLevel(LogLevel.Fatal, TARGET_ERROR_EX)
        config.AddRuleForOneLevel(LogLevel.Warn, TARGET_ERROR)
        config.AddRuleForOneLevel(LogLevel.Warn, TARGET_DEFAULT)
        config.AddRuleForOneLevel(LogLevel.Info, TARGET_DEFAULT)
    End Sub

    ''' <summary>
    ''' Returns the full path of the current default log file.
    ''' </summary>
    ''' <returns>Full path of the current default log file</returns>
    Private Function GetCurrentLogFilePath()
        Dim logEventInfo As New LogEventInfo() With {.TimeStamp = Date.Now}
        Dim target As FileTarget = config.FindTargetByName(TARGET_DEFAULT)
        Dim fileName As String = target.FileName.Render(logEventInfo)

        Return fileName
    End Function

    ''' <summary>
    ''' Reconfigures and re-adds all loggers, optionally adding the debug rule.
    ''' </summary>
    ''' <param name="Debug">Adds the Debug rule if true.</param>
    Private Sub ReloadConfig(Optional Debug As Boolean = False)
        ' Clear Logging Rules
        config.LoggingRules.Clear()

        ' Add default rules
        AddDefaultRules(config)

        ' Add debug rule, if configured
        If Debug Then
            config.AddRuleForOneLevel(LogLevel.Debug, TARGET_DEBUG)
            config.AddRuleForAllLevels(TARGET_MEMORY)
        End If

        ' Reload all running loggers
        LogFactory.ReconfigExistingLoggers()
    End Sub

#Region "Log Targets"
    Private Function GetDefaultLogTarget(basePath As String) As FileTarget
        Dim defaultLog As New FileTarget() With {
            .FileName = Path.Combine(basePath, FILE_NAME_FORMAT_DEFAULT),
            .Name = TARGET_DEFAULT,
            .Layout = LOG_FORMAT_DEFAULT,
            .MaxArchiveFiles = MAX_ARCHIVE_FILES_DEFAULT,
            .ArchiveEvery = ARCHIVE_EVERY,
            .KeepFileOpen = KEEP_FILES_OPEN
        }

        Return defaultLog
    End Function
    Private Function GetErrorExceptionLogTarget(basePath As String) As FileTarget
        Dim errorLogWithExceptions As New FileTarget() With {
            .FileName = Path.Combine(basePath, FILE_NAME_FORMAT_ERROR),
            .Name = TARGET_ERROR_EX,
            .Layout = LOG_FORMAT_EXCEPTION,
            .MaxArchiveFiles = MAX_ARCHIVE_FILES_DEFAULT,
            .ArchiveEvery = ARCHIVE_EVERY,
            .KeepFileOpen = KEEP_FILES_OPEN
        }

        Return errorLogWithExceptions
    End Function

    Private Function GetErrorLogTarget(basePath As String) As FileTarget
        Dim errorLog As New FileTarget() With {
            .FileName = Path.Combine(basePath, FILE_NAME_FORMAT_ERROR),
            .Name = TARGET_ERROR,
            .Layout = LOG_FORMAT_DEFAULT,
            .MaxArchiveFiles = MAX_ARCHIVE_FILES_DEFAULT,
            .ArchiveEvery = ARCHIVE_EVERY,
            .KeepFileOpen = KEEP_FILES_OPEN
        }

        Return errorLog
    End Function

    Private Function GetDetailLogTarget(basePath As String) As FileTarget
        Dim detailLog As New FileTarget() With {
            .FileName = Path.Combine(basePath, FILE_NAME_FORMAT_DETAIL),
            .Name = TARGET_DETAIL,
            .Layout = LOG_FORMAT_DEFAULT,
            .MaxArchiveFiles = MAX_ARCHIVE_FILES_DEBUG_DETAIL,
            .ArchiveEvery = ARCHIVE_EVERY,
            .KeepFileOpen = KEEP_FILES_OPEN
        }

        Return detailLog
    End Function
    Private Function GetDebugLogTarget(basePath As String) As FileTarget
        Dim debugLog As New FileTarget() With {
            .FileName = Path.Combine(basePath, FILE_NAME_FORMAT_DEBUG),
            .Name = TARGET_DEBUG,
            .Layout = LOG_FORMAT_DEFAULT,
            .MaxArchiveFiles = MAX_ARCHIVE_FILES_DEBUG_DETAIL,
            .ArchiveEvery = ARCHIVE_EVERY,
            .KeepFileOpen = KEEP_FILES_OPEN
        }

        Return debugLog
    End Function

    Private Function GetMemoryDebugTarget() As MemoryTarget
        Dim memoryLog As New MemoryTarget() With {
            .Layout = LOG_FORMAT_MEMORY,
            .Name = TARGET_MEMORY,
            .OptimizeBufferReuse = True
        }

        Return memoryLog
    End Function
#End Region
End Class
