Imports System.IO
Imports System.Security.Cryptography
Imports System.Text
Imports System.Text.RegularExpressions
Imports DigitalData.Modules.Logging

''' <module>File</module>
''' <version>0.0.0.1</version>
''' <date>11.10.2018</date>
''' <summary>
''' Module that provides variouse File operations
''' </summary>
''' <dependencies>
''' NLog, >= 4.5.8
''' </dependencies>
''' <params>
''' LogConfig, DigitalData.Module.Logging.LogConfig
'''     A LogConfig object               
''' </params>
''' <props>
''' </props>
''' <example>
''' </example>
''' <remarks>
''' </remarks>
Public Class File
    Private ReadOnly _Logger As Logger
    Private ReadOnly _LogConfig As LogConfig

    Private ReadOnly _invalidFilenameChars As String
    Private ReadOnly _invalidPathChars As String

    Private Const REGEX_CLEAN_FILENAME As String = "[\\/:""<>|\b\0\r\n\t]"
    Private Const REGEX_CLEAN_PATH As String = "[""<>|\b\0\r\n\t]"

    ' The limit enforced by windows for filenpaths is 260, 
    ' so we use a slightly smaller number to have some Error margin.
    ' 
    ' Source: https://docs.microsoft.com/de-de/windows/win32/fileio/naming-a-file?redirectedfrom=MSDN#maximum-path-length-limitation
    Private Const MAX_FILE_PATH_LENGTH = 250

    Private Const FILE_NAME_ACCESS_TEST = "accessTest.txt"

    Public Sub New(LogConfig As LogConfig)
        _LogConfig = LogConfig
        _Logger = LogConfig.GetLogger()

        _invalidFilenameChars = String.Join("", Path.GetInvalidFileNameChars())
        _invalidPathChars = String.Join("", Path.GetInvalidPathChars())
    End Sub

    Public Function GetCleanFilename(FileName As String) As String
        _Logger.Debug("Filename before cleaning: [{0}]", FileName)

        Dim oCleanName As String = FileName
        oCleanName = Regex.Replace(oCleanName, _invalidFilenameChars, String.Empty)
        oCleanName = Regex.Replace(oCleanName, REGEX_CLEAN_FILENAME, String.Empty, RegexOptions.Singleline)
        oCleanName = Regex.Replace(oCleanName, "\s{2,}", " ")
        oCleanName = Regex.Replace(oCleanName, "\.{2,}", ".")

        _Logger.Debug("Filename after cleaning: [{0}]", oCleanName)

        Return oCleanName
    End Function

    Public Function GetCleanPath(FilePath As String) As String
        _Logger.Debug("Path before cleaning: [{0}]", FilePath)

        Dim oCleanName As String = FilePath
        oCleanName = Regex.Replace(oCleanName, _invalidPathChars, String.Empty)
        oCleanName = Regex.Replace(oCleanName, REGEX_CLEAN_PATH, String.Empty, RegexOptions.Singleline)

        _Logger.Debug("Path after cleaning: [{0}]", oCleanName)

        Return oCleanName
    End Function

    ''' <summary>
    ''' Reads the file at `FilePath` and computes a SHA256 Hash from its contents
    ''' </summary>
    ''' <param name="FilePath"></param>
    ''' <returns></returns>
    Public Function GetChecksum(FilePath As String) As String
        Try
            Using oFileStream = IO.File.OpenRead(FilePath)
                Using oStream As New BufferedStream(oFileStream, 1200000)
                    Dim oChecksum() As Byte = SHA256.Create.ComputeHash(oStream)
                    Return FormatHash(oChecksum)
                End Using
            End Using
        Catch ex As Exception
            _Logger.Error(ex)
            Return Nothing
        End Try
    End Function

    Public Function GetChecksumFromString(pStringToCheck As String) As String
        Dim oBytes() As Byte = Encoding.UTF8.GetBytes(pStringToCheck)
        Dim oChecksum() As Byte = SHA256.Create.ComputeHash(oBytes)
        Return FormatHash(oChecksum)
    End Function

    Private Function FormatHash(pChecksum)
        Return BitConverter.
            ToString(pChecksum).
            Replace("-", String.Empty)
    End Function

    ''' <summary>
    ''' Adds file version string to given filename `Destination` if that file already exists.
    ''' </summary>
    ''' <param name="Destination"></param>
    ''' <returns></returns>
    Public Function GetVersionedFilename(Destination As String) As String
        Try
            Dim oFileName As String = Destination
            Dim oFinalFileName = oFileName

            Dim oDestinationDir = Path.GetDirectoryName(oFileName)
            Dim oExtension = Path.GetExtension(oFileName)

            Dim oVersionSeparator As Char = "~"c

            ' Split Filename without extension at version separator to:
            ' - Check if file is already versioned
            ' - Get the file version of an already versioned file
            ' 
            ' Example: 
            ' test1.pdf         --> test1           --> ['test1']               --> no fileversion
            ' test1~2.pdf       --> test1~2         --> ['test1', '2']          --> version 2
            ' test1~12345~2.pdf --> test1~12345~2   --> ['test1', '12345', '2'] --> still version 2
            'Dim oFileNameWithoutExtension = Path.GetFileNameWithoutExtension(oFileName)
            'Dim oSplitFilename = oFileNameWithoutExtension.Split(oVersionSeparator).ToList()

            ' if file is already versioned, extract file version
            ' else just use the filename and set version to 1
            'If oSplitFilename.Count > 1 Then
            '    Dim oVersion As Integer = 1
            '    Try
            '        oVersion = Integer.Parse(oSplitFilename.Last())
            '        oFileNameWithoutExtension = String.Join("", oSplitFilename.Take(oSplitFilename.Count - 1))
            '    Catch ex As Exception
            '        ' oFilenameWithoutExtension does NOT change
            '        oFileNameWithoutExtension = oFileNameWithoutExtension
            '    Finally
            '        oFileVersion = oVersion
            '    End Try
            'Else
            '    oFileVersion = 1
            'End If

            Dim oFileNameWithoutExtension = Path.GetFileNameWithoutExtension(oFileName)
            Dim oSplitResult = GetVersionedString(oFileNameWithoutExtension, oVersionSeparator)

            oFileNameWithoutExtension = oSplitResult.Item1
            Dim oFileVersion = oSplitResult.Item2

            ' Shorten the filename (only filename, without extension or version)
            ' by cutting the length in half. This should work no matter how long the path and/or filename are.
            If oFileName.Length > MAX_FILE_PATH_LENGTH Then
                _Logger.Info("Filename is too long. Filename will be cut to prevent further errors.")
                _Logger.Info("Original Filename is: {0}", oFileNameWithoutExtension)
                Dim oNewLength As Integer = Math.Round(oFileNameWithoutExtension.Length / 2)
                Dim oNewFileNameWithoutExtension = oFileNameWithoutExtension.Substring(0, oNewLength)
                _Logger.Info("New Filename will be: {0}", oNewFileNameWithoutExtension)

                oFileNameWithoutExtension = oNewFileNameWithoutExtension
            End If

            ' while file exists, increment version    
            Do
                oFinalFileName = Path.Combine(oDestinationDir, GetFilenameWithVersion(oFileNameWithoutExtension, oVersionSeparator, oFileVersion, oExtension))
                _Logger.Debug("Intermediate Filename is {0}", oFinalFileName)
                _Logger.Debug("File version: {0}", oFileVersion)
                oFileVersion += 1
            Loop While (IO.File.Exists(oFinalFileName))

            _Logger.Debug("Final Filename is {0}", oFinalFileName)

            Return oFinalFileName
        Catch ex As Exception
            _Logger.Warn("Filename {0} could not be versioned. Original filename will be returned!", Destination)
            _Logger.Error(ex)
            Return Destination
        End Try
    End Function

    ''' <summary>
    ''' Split String at version separator to:
    ''' check if string is already versioned,
    ''' get the string version of an already versioned string
    ''' </summary>
    ''' <example>
    ''' Examples: 
    ''' test1.pdf         --> test1           --> ['test1']               --> no fileversion
    ''' test1~2.pdf       --> test1~2         --> ['test1', '2']          --> version 2
    ''' test1~12345~2.pdf --> test1~12345~2   --> ['test1', '12345', '2'] --> still version 2
    ''' somestring~3      --> somestring~3    --> ['somestring', '3']     --> version 3
    ''' </example>
    ''' <param name="pString">The string to versioned</param>
    ''' <param name="pSeparator">The character to split at</param>
    ''' <returns>Tuple of string and version</returns>
    Public Function GetVersionedString(pString As String, pSeparator As Char) As Tuple(Of String, Integer)
        Dim oSplitString = pString.Split(pSeparator).ToList()
        Dim oStringVersion As Integer

        ' if string is already versioned, extract string version
        ' else just use the string and set version to 1
        If oSplitString.Count > 1 Then
            Dim oVersion As Integer = 1
            Try
                oVersion = Integer.Parse(oSplitString.Last())
                pString = String.Join("", oSplitString.Take(oSplitString.Count - 1))
            Catch ex As Exception
                ' pString does NOT change
                pString = pString
            Finally
                oStringVersion = oVersion
            End Try
        Else
            oStringVersion = 1
        End If

        Return New Tuple(Of String, Integer)(pString, oStringVersion)
    End Function

    Public Function GetAppDataPath(CompanyName As String, ProductName As String)
        Dim oLocalAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)
        Return Path.Combine(oLocalAppData, CompanyName, ProductName)
    End Function

    Private Function GetFilenameWithVersion(FileNameWithoutExtension As String, VersionSeparator As Char, FileVersion As Integer, Extension As String) As String
        If FileVersion <= 1 Then
            Return $"{FileNameWithoutExtension}{Extension}"
        Else
            Return $"{FileNameWithoutExtension}{VersionSeparator}{FileVersion}{Extension}"
        End If
    End Function

    ''' <summary>
    ''' Removes files in a directory filtered by filename, extension and last write date
    ''' </summary>
    ''' <param name="Path">The directory in which files will be deleted</param>
    ''' <param name="FileKeepTime">Only delete files which are older than x days. Must be between 0 and 1000 days.</param>
    ''' <param name="FileBaseName">A filename filter which will be checked</param>
    ''' <param name="FileExtension">A file extension which will be checked</param>
    ''' <param name="ContinueOnError">Should the function continue with deleting when a file could not be deleted?</param>
    ''' <returns>True if all files were deleted or if no files were deleted, otherwise false</returns>
    Public Function RemoveFiles(Path As String, FileKeepTime As Integer, FileBaseName As String, Optional FileExtension As String = "log", Optional ContinueOnError As Boolean = True) As Boolean
        If Not TestPathIsDirectory(Path) Then
            Throw New ArgumentException($"Path {Path} is not a directory!")
        End If

        If Not Directory.Exists(Path) Then
            Throw New DirectoryNotFoundException($"Path {Path} does not exist!")
        End If

        If FileKeepTime < 0 Or FileKeepTime > 1000 Then
            Throw New ArgumentOutOfRangeException("FileKeepTime must be an integer between 0 and 1000!")
        End If

        Dim oUnableToDeleteCounter = 0
        Dim oDirectory As New DirectoryInfo(Path)
        Dim oDateLimit As DateTime = DateTime.Now.AddDays(FileKeepTime)
        Dim oFiles As List(Of FileInfo) = oDirectory.
            EnumerateFiles($"*{FileBaseName}*").
            Where(Function(oFileInfo As FileInfo)
                      Return oFileInfo.Extension = FileExtension And oFileInfo.LastWriteTime < oDateLimit
                  End Function).
                  ToList()

        If oFiles.Count = 0 Then
            _Logger.Debug("No files found that match the criterias.")
            Return True
        End If

        _Logger.Debug("Deleting old files (Found {0}).", oFiles.Count)

        For Each oFile As FileInfo In oFiles
            Try
                oFile.Delete()
            Catch ex As Exception
                If ContinueOnError = False Then
                    _Logger.Warn("Deleting files was aborted at file {0}.", oFile.FullName)
                    Return False
                End If
                oUnableToDeleteCounter = oUnableToDeleteCounter + 1
                _Logger.Warn("File {0} could not be deleted!")
            End Try
        Next

        If oUnableToDeleteCounter > 0 Then
            _Logger.Debug("Old files partially removed. {0} files could not be removed.", oUnableToDeleteCounter)
        Else
            _Logger.Debug("Old files removed.")
        End If

        Return True
    End Function

    <DebuggerStepThrough>
    Public Sub MoveTo(FilePath As String, Directory As String)
        Dim oFileInfo As New FileInfo(FilePath)
        IO.File.Move(FilePath, Path.Combine(Directory, oFileInfo.Name))
    End Sub

    <DebuggerStepThrough>
    Public Sub MoveTo(FilePath As String, NewFileName As String, Directory As String)
        IO.File.Move(FilePath, Path.Combine(Directory, NewFileName))
    End Sub

    ''' <summary>
    ''' Copied from https://docs.microsoft.com/en-us/dotnet/standard/io/how-to-copy-directories
    ''' </summary>
    ''' <param name="SourceDirName"></param>
    ''' <param name="DestDirName"></param>
    ''' <param name="CopySubDirs"></param>
    Public Sub CopyDirectory(ByVal SourceDirName As String, ByVal DestDirName As String, ByVal CopySubDirs As Boolean)
        Dim oDirectory As DirectoryInfo = New DirectoryInfo(SourceDirName)

        If Not oDirectory.Exists Then
            Throw New DirectoryNotFoundException("Source directory does not exist or could not be found: " & SourceDirName)
        End If

        Dim oDirectories As DirectoryInfo() = oDirectory.GetDirectories()
        Directory.CreateDirectory(DestDirName)
        Dim oFiles As FileInfo() = oDirectory.GetFiles()

        For Each oFile As FileInfo In oFiles
            Dim tempPath As String = Path.Combine(DestDirName, oFile.Name)
            oFile.CopyTo(tempPath, False)
        Next

        If CopySubDirs Then
            For Each oSubDirectory As DirectoryInfo In oDirectories
                Dim oTempPath As String = Path.Combine(DestDirName, oSubDirectory.Name)
                CopyDirectory(oSubDirectory.FullName, oTempPath, CopySubDirs)
            Next
        End If
    End Sub

    ''' <summary>
    ''' Tries to create a directory and returns its path.
    ''' Returns a temp path if `DirectoryPath` can not be created or written to.
    ''' </summary>
    ''' <param name="DirectoryPath">The directory to create</param>
    ''' <param name="TestWriteAccess">Should a write access test be performed?</param>
    ''' <returns>The used path</returns>
    Public Function CreateDirectory(DirectoryPath As String, Optional TestWriteAccess As Boolean = True) As String
        Dim oFinalPath As String
        If Directory.Exists(DirectoryPath) Then
            _Logger.Debug("Directory {0} already exists. Skipping.", DirectoryPath)
            oFinalPath = DirectoryPath
        Else
            Try
                Directory.CreateDirectory(DirectoryPath)
                oFinalPath = DirectoryPath
            Catch ex As Exception
                _Logger.Error(ex)
                _Logger.Warn("Directory {0} could not be created. Temp path will be used instead.", DirectoryPath)
                oFinalPath = Path.GetTempPath()
            End Try
        End If

        If TestWriteAccess AndAlso Not TestPathIsWritable(DirectoryPath) Then
            _Logger.Warn("Directory {0} is not writable. Temp path will be used instead.", DirectoryPath)
            oFinalPath = Path.GetTempPath()
        Else
            oFinalPath = DirectoryPath
        End If

        _Logger.Debug("Using path {0}", oFinalPath)

        Return oFinalPath
    End Function

    Public Function TestPathIsWritable(DirectoryPath As String) As Boolean
        Try
            Dim fileAccessPath = Path.Combine(DirectoryPath, FILE_NAME_ACCESS_TEST)
            Using fs As FileStream = IO.File.Create(fileAccessPath)
                fs.WriteByte(0)
            End Using

            IO.File.Delete(fileAccessPath)
            Return True
        Catch ex As Exception
            Return False
        End Try
    End Function

    ''' <summary>
    ''' Checks if a file is locked, ie. in use by another process.
    ''' </summary>
    ''' <remarks>
    ''' https://docs.microsoft.com/en-us/dotnet/standard/io/handling-io-errors
    ''' https://stackoverflow.com/questions/876473/is-there-a-way-to-check-if-a-file-is-in-use
    ''' </remarks>
    Public Function TestFileIsLocked(pFilePath As String) As Boolean
        Try
            Using stream As FileStream = IO.File.Open(pFilePath, FileMode.Open, FileAccess.ReadWrite, FileShare.None)
                stream.Close()
            End Using
        Catch ex As Exception When ((ex.HResult And &HFFFF) = 32)
            Return True
        Catch ex As Exception
            Return True
        End Try

        Return False
    End Function

    Public Function TestPathIsDirectory(Path As String) As Boolean
        If Not Directory.Exists(Path) Then
            Return False
        End If

        Dim oIsDirectory As Boolean = (System.IO.File.GetAttributes(Path) And FileAttributes.Directory) = FileAttributes.Directory
        Return oIsDirectory
    End Function

    Public Function GetDateDirectory(pBaseDirectory As String, pDate As Date) As String
        Dim oDateDirectory = GetDateString(pDate)
        Dim oFinalDirectory As String = IO.Path.Combine(pBaseDirectory, oDateDirectory)
        Return oFinalDirectory
    End Function

    Public Function GetDateDirectory(pBaseDirectory As String) As String
        Return GetDateDirectory(pBaseDirectory, Now)
    End Function

    Public Function CreateDateDirectory(pBaseDirectory As String, pDate As Date) As String
        Dim oDateDirectory = GetDateString(pDate)
        Dim oFinalDirectory As String = IO.Path.Combine(pBaseDirectory, oDateDirectory)

        If IO.Directory.Exists(oFinalDirectory) = False Then
            _Logger.Debug("Path does not exist, creating: [{0}]", oFinalDirectory)
            Try
                Directory.CreateDirectory(oFinalDirectory)
                _Logger.Debug("Created folder [{0}]", oFinalDirectory)
            Catch ex As Exception
                _Logger.Warn("Final path [{0}] could not be created!", oFinalDirectory)
                _Logger.Error(ex)
            End Try
        End If

        Return oFinalDirectory
    End Function

    Public Function CreateDateDirectory(pBaseDirectory As String) As String
        Return CreateDateDirectory(pBaseDirectory, Now)
    End Function

    Public Function GetDateString() As String
        Return $"{Now:yyyy\\MM\\dd}"
    End Function

    Public Function GetDateString(pDate As Date) As String
        Return $"{pDate:yyyy\\MM\\dd}"
    End Function

    Public Function GetDateTimeString() As String
        Return $"{Now:yyyy-MM-dd_hh-mm-ffff}"
    End Function

    Public Function GetDateTimeString(pDate As Date) As String
        Return $"{pDate:yyyy-MM-dd_hh-mm-ffff}"
    End Function

    Public Function GetFilenameWithSuffix(pFilePath As String, pSuffix As String)
        Dim oFileInfo = New IO.FileInfo(pFilePath)
        Return GetFilenameWithSuffix(IO.Path.GetFileNameWithoutExtension(pFilePath), pSuffix, oFileInfo.Extension.Substring(1))
    End Function

    Public Function GetFilenameWithSuffix(pBaseString As String, pSuffix As String, pExtension As String)
        Return $"{pBaseString}-{pSuffix}.{pExtension}"
    End Function

    Public Function GetFilenameWithPrefix(pFilePath As String, pPrefix As String)
        Dim oFileInfo = New IO.FileInfo(pFilePath)
        Return GetFilenameWithSuffix(IO.Path.GetFileNameWithoutExtension(pFilePath), pPrefix, oFileInfo.Extension.Substring(1))
    End Function

    Public Function GetFilenameWithPrefix(pBaseString As String, pPrefix As String, pExtension As String)
        Return $"{pPrefix}-{pBaseString}.{pExtension}"
    End Function

End Class
