#nullable enable
using System;
using System.Collections.Generic;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading;
using System.Xml.Linq;
using System.Text.RegularExpressions;
using Barotrauma.IO;
using Microsoft.Xna.Framework;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Barotrauma.Networking;

namespace Barotrauma
{
    public readonly struct CampaignDataPath
    {
        public readonly string LoadPath;
        public readonly string SavePath;

        public CampaignDataPath(string loadPath, string savePath)
        {
            if (IsBackupPath(savePath, out _))
            {
                throw new ArgumentException("Save path cannot be a backup path.", nameof(savePath));
            }

            LoadPath = loadPath;
            SavePath = savePath;
        }

        /// <summary>
        /// Empty path used for non-campaign game sessions.
        /// </summary>
        public static readonly CampaignDataPath Empty = new CampaignDataPath(loadPath: string.Empty, savePath: string.Empty);

        /// <summary>
        /// Creates a CampaignDataPath with the same load and save path.
        /// </summary>
        public static CampaignDataPath CreateRegular(string savePath)
            => new CampaignDataPath(savePath, savePath);

        public static bool IsBackupPath(string path, out uint foundIndex)
        {
            string extension = Path.GetExtension(path);
            bool startsWith = extension.StartsWith(SaveUtil.BackupExtension, StringComparison.OrdinalIgnoreCase);

            if (!startsWith)
            {
                foundIndex = 0;
                return false;
            }

            bool hasIndex = SaveUtil.TryGetBackupIndexFromFileName(path, out foundIndex);
            return hasIndex;
        }
    }

    static class SaveUtil
    {
        public const string GameSessionFileName = "gamesession.xml";

        private static readonly string LegacySaveFolder = Path.Combine("Data", "Saves");
        private static readonly string LegacyMultiplayerSaveFolder = Path.Combine(LegacySaveFolder, "Multiplayer");

#if OSX
        /// <summary>
        /// These exist because we used to have a workaround here that set the save folder to 
        /// Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "Library", "Application Support", "Daedalic Entertainment GmbH", "Barotrauma")
        /// on Mac, because apparently LocalApplicationData returned something different than the expected path. That seems to have changed in .NET8, and now we
        /// can use the same LocalApplicationData on all platforms. We however still check the old path in case someone has their saves there.
        /// </summary>
        public static readonly string LegacyMacSaveFolder = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.Personal), 
            "Library",
            "Application Support",
            "Daedalic Entertainment GmbH",
            "Barotrauma");
        public static string LegacyMacMultiplayerSaveFolder = Path.Combine(LegacyMacSaveFolder, "Multiplayer");
#endif

        //C:/Users/*user*/AppData/Local/Daedalic Entertainment GmbH/ on Windows
        ///home/*user*/.local/share/Daedalic Entertainment GmbH/ on Linux
        ///Users/*user*/Library/Application Support/Daedalic Entertainment GmbH/ on Mac
        public static readonly string DefaultSaveFolder = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
            "Daedalic Entertainment GmbH",
            "Barotrauma");

        public static string DefaultMultiplayerSaveFolder = Path.Combine(DefaultSaveFolder, "Multiplayer");

        public static readonly string SubmarineDownloadFolder = Path.Combine("Submarines", "Downloaded");
        public static readonly string CampaignDownloadFolder = Path.Combine("Data", "Saves", "Multiplayer_Downloaded");

        public const string BackupExtension = ".bk";

        /// <summary>
        /// .save.bk
        /// </summary>
        public const string FullBackupExtension = $".save{BackupExtension}";

        /// <summary>
        /// .save.bk0
        /// </summary>
        public const string BackupExtensionFormat = $"{FullBackupExtension}{{0}}";

        /// <summary>
        /// .xml.bk
        /// </summary>
        public const string BackupCharacterDataExtensionStart = $".xml{BackupExtension}";

        /// <summary>
        /// .xml.bk0
        /// </summary>
        public const string BackupCharacterDataFormat = $"{BackupCharacterDataExtensionStart}{{0}}";

        public static int MaxBackupCount = 3;

        public static string TempPath
        {
#if SERVER
            get { return Path.Combine(GetSaveFolder(SaveType.Singleplayer), "temp_server"); }
#else
            get            

            {
                string tempFolder = Path.Combine(GetSaveFolder(SaveType.Singleplayer), "temp");
#if DEBUG
                if (GameClient.MultiClientTestMode && GameMain.Client != null)
                {
                    //append the name of the client to the download folder to avoid multiple clients
                    //from trying to download a file into the same path at the same time
                    tempFolder += "_" + GameMain.Client.Name;
                }
#endif
                return tempFolder;
            }
#endif
        }

        public static void EnsureSaveFolderExists()
        {
            try
            {
                // Create the default save folder (only) if it doesn't exist yet.
                // note, uses System.IO.Directory.CreateDirectory instead of Directory.CreateDirectory from Baro namespace on purpose.
                System.IO.Directory.CreateDirectory(DefaultSaveFolder);
            }
            catch (Exception e)
            {
                DebugConsole.ThrowError($"Failed to create the default save folder \"{DefaultSaveFolder}\"!", e);
            }
        }

        public enum SaveType
        {
            Singleplayer,
            Multiplayer
        }

        /// <summary>
        /// Saves the game to a file.
        /// </summary>
        /// <param name="filePath">The path to the save file. </param>
        /// <param name="isSavingOnLoading">
        /// Indicates if the save is happening during loading in multiplayer
        /// to ensure the campaign ID matches the one in the save file.
        /// Used to work around some quirks with the backup system.
        /// </param>
        public static void SaveGame(CampaignDataPath filePath, bool isSavingOnLoading = false)
        {
            if (!isSavingOnLoading && File.Exists(filePath.SavePath))
            {
                BackupSave(filePath.SavePath);
            }

            DebugConsole.Log("Saving the game to: " + filePath);
            Directory.CreateDirectory(TempPath, catchUnauthorizedAccessExceptions: true);
            try
            {
                ClearFolder(TempPath, new string[] { GameMain.GameSession.SubmarineInfo.FilePath });
            }
            catch (Exception e)
            {
                LogErrorAndSendToClients("Failed to clear folder", e);
                return;
            }

            try
            {
                GameMain.GameSession.Save(Path.Combine(TempPath, GameSessionFileName), isSavingOnLoading);

                if (!isSavingOnLoading)
                {
                    // Reset the campaign data path, since if we had a different load path, it would be invalid now
                    GameMain.GameSession.DataPath = CampaignDataPath.CreateRegular(filePath.SavePath);
                }
            }
            catch (Exception e)
            {
                LogErrorAndSendToClients("Error saving gamesession", e);
                return;
            }

            try
            {
                string? mainSubPath = null;
                if (GameMain.GameSession.SubmarineInfo != null)
                {
                    mainSubPath = Path.Combine(TempPath, GameMain.GameSession.SubmarineInfo.Name + ".sub");
                    GameMain.GameSession.SubmarineInfo.SaveAs(mainSubPath);
                    for (int i = 0; i < GameMain.GameSession.OwnedSubmarines.Count; i++)
                    {
                        if (GameMain.GameSession.OwnedSubmarines[i].Name == GameMain.GameSession.SubmarineInfo.Name)
                        {
                            GameMain.GameSession.OwnedSubmarines[i] = GameMain.GameSession.SubmarineInfo;
                        }
                    }
                }

                if (GameMain.GameSession.OwnedSubmarines != null)
                {
                    for (int i = 0; i < GameMain.GameSession.OwnedSubmarines.Count; i++)
                    {
                        SubmarineInfo storedInfo = GameMain.GameSession.OwnedSubmarines[i];
                        string subPath = Path.Combine(TempPath, storedInfo.Name + ".sub");
                        if (mainSubPath == subPath) { continue; }
                        storedInfo.SaveAs(subPath);
                    }
                }
            }
            catch (Exception e)
            {
                LogErrorAndSendToClients("Error saving submarine", e);
                return;
            }

            try
            {
                CompressDirectory(TempPath, filePath.SavePath);
            }
            catch (Exception e)
            {
                LogErrorAndSendToClients("Error compressing save file", e);
            }

            void LogErrorAndSendToClients(string errorMsg, Exception e)
            {
                DebugConsole.ThrowError(errorMsg, e);
#if SERVER
                if (GameMain.Server != null)
                {
                    foreach (var client in GameMain.Server.ConnectedClients)
                    {
                        GameMain.Server.SendDirectChatMessage(errorMsg + '\n' + e.StackTrace.CleanupStackTrace(), client, ChatMessageType.Error);
                    }
                }
#endif
            }
        }

        public static void LoadGame(CampaignDataPath path)
        {
            //ensure there's no gamesession/sub loaded because it'd lead to issues when starting a new one (e.g. trying to determine which level to load based on the placement of the sub)
            //can happen if a gamesession is interrupted ungracefully (exception during loading)
            Submarine.Unload();
            GameMain.GameSession = null;
            DebugConsole.Log("Loading save file: " + path.LoadPath);
            DecompressToDirectory(path.LoadPath, TempPath);

            XDocument doc = XMLExtensions.TryLoadXml(Path.Combine(TempPath, GameSessionFileName));
            if (doc == null) { return; }

            if (!IsSaveFileCompatible(doc))
            {
                throw new Exception($"The save file \"{path.LoadPath}\" is not compatible with this version of Barotrauma.");
            }

            var ownedSubmarines = LoadOwnedSubmarines(doc, out SubmarineInfo selectedSub);
            GameMain.GameSession = new GameSession(selectedSub, ownedSubmarines, doc, path);
        }

        public static List<SubmarineInfo> LoadOwnedSubmarines(XDocument saveDoc, out SubmarineInfo selectedSub)
        {
            string subPath = Path.Combine(TempPath, saveDoc.Root.GetAttributeString("submarine", "")) + ".sub";
            selectedSub = new SubmarineInfo(subPath);

            List<SubmarineInfo> ownedSubmarines = new List<SubmarineInfo>();

            var ownedSubsElement = saveDoc.Root?.Element("ownedsubmarines");
            if (ownedSubsElement == null) { return ownedSubmarines; }

            foreach (var subElement in ownedSubsElement.Elements())
            {
                string subName = subElement.GetAttributeString("name", "");
                string ownedSubPath = Path.Combine(TempPath, subName + ".sub");
                if (!File.Exists(ownedSubPath))
                {
                    DebugConsole.ThrowError($"Could not find the submarine \"{subName}\" ({ownedSubPath})! The save file may be corrupted. Removing the submarine from owned submarines...");
                }
                else
                {
                    ownedSubmarines.Add(new SubmarineInfo(ownedSubPath));
                }
            }
            return ownedSubmarines;
        }

        public static bool IsSaveFileCompatible(XDocument? saveDoc)
            => IsSaveFileCompatible(saveDoc?.Root);

        public static bool IsSaveFileCompatible(XElement? saveDocRoot)
        {
            if (saveDocRoot?.Attribute("version") == null) { return false; }
            return true;
        }

        public static void DeleteSave(string filePath)
        {
            try
            {
                File.Delete(filePath, catchUnauthorizedAccessExceptions: false);

                string[] backups = GetBackupPaths(Path.GetDirectoryName(filePath) ?? "", Path.GetFileNameWithoutExtension(filePath));
                foreach (string backup in backups)
                {
                    File.Delete(backup, catchUnauthorizedAccessExceptions: false);
                }
            }
            catch (Exception e)
            {
                DebugConsole.ThrowError("ERROR: deleting save file \"" + filePath + "\" failed.", e);
            }

            //deleting a multiplayer save file -> also delete character data
            var fullPath = Path.GetFullPath(Path.GetDirectoryName(filePath) ?? "");

            if (fullPath.Equals(Path.GetFullPath(DefaultMultiplayerSaveFolder)) ||
                fullPath == Path.GetFullPath(GetSaveFolder(SaveType.Multiplayer)))
            {
                string characterDataSavePath = MultiPlayerCampaign.GetCharacterDataSavePath(filePath);
                if (File.Exists(characterDataSavePath))
                {
                    try
                    {
                        File.Delete(characterDataSavePath, catchUnauthorizedAccessExceptions: false);
                    }
                    catch (Exception e)
                    {
                        DebugConsole.ThrowError("ERROR: deleting character data file \"" + characterDataSavePath + "\" failed.", e);
                    }
                }
            }
        }

        public static string GetSaveFolder(SaveType saveType)
        {
            string folder = string.Empty;

            if (!string.IsNullOrEmpty(GameSettings.CurrentConfig.SavePath))
            {
                folder = GameSettings.CurrentConfig.SavePath;
                if (saveType == SaveType.Multiplayer)
                {
                    folder = Path.Combine(folder, "Multiplayer");
                }
                if (!Directory.Exists(folder))
                {
                    DebugConsole.AddWarning($"Could not find the custom save folder \"{folder}\", creating the folder...");
                    try
                    {
                        Directory.CreateDirectory(folder, catchUnauthorizedAccessExceptions: false);
                    }
                    catch (Exception e)
                    {
                        DebugConsole.ThrowError($"Could not find the custom save folder \"{folder}\". Using the default save path instead.", e);
                        folder = string.Empty;
                    }
                }
            }
            if (string.IsNullOrEmpty(folder))
            {
                folder = saveType == SaveType.Singleplayer ? DefaultSaveFolder : DefaultMultiplayerSaveFolder;
            }
            return folder;
        }

        public static IReadOnlyList<CampaignMode.SaveInfo> GetSaveFiles(SaveType saveType, bool includeInCompatible = true, bool logLoadErrors = true)
        {
            string defaultFolder = saveType == SaveType.Singleplayer ? DefaultSaveFolder : DefaultMultiplayerSaveFolder;
            if (!Directory.Exists(defaultFolder))
            {
                DebugConsole.Log("Save folder \"" + defaultFolder + " not found! Attempting to create a new folder...");
                try
                {
                    Directory.CreateDirectory(defaultFolder, catchUnauthorizedAccessExceptions: false);
                }
                catch (Exception e)
                {
                    DebugConsole.ThrowError("Failed to create the folder \"" + defaultFolder + "\"!", e);
                }
            }

            List<string> files = Directory.GetFiles(defaultFolder, "*.save", System.IO.SearchOption.TopDirectoryOnly).ToList();

            var folder = GetSaveFolder(saveType);
            if (!string.IsNullOrEmpty(folder) && Directory.Exists(folder))
            {
                files.AddRange(Directory.GetFiles(folder, "*.save", System.IO.SearchOption.TopDirectoryOnly));
            }

            string legacyFolder = saveType == SaveType.Singleplayer ? LegacySaveFolder : LegacyMultiplayerSaveFolder;
            if (Directory.Exists(legacyFolder))
            {
                files.AddRange(Directory.GetFiles(legacyFolder, "*.save", System.IO.SearchOption.TopDirectoryOnly));
            }

#if OSX
            string legacyMacFolder = saveType == SaveType.Singleplayer ? LegacyMacSaveFolder : LegacyMacMultiplayerSaveFolder;
            if (Directory.Exists(legacyMacFolder))
            {
                files.AddRange(Directory.GetFiles(legacyMacFolder, "*.save", System.IO.SearchOption.TopDirectoryOnly));
            }
#endif
            files = files.Distinct().ToList();

            List<CampaignMode.SaveInfo> saveInfos = new List<CampaignMode.SaveInfo>();   
            foreach (string file in files)
            {
                var docRoot = ExtractGameSessionRootElementFromSaveFile(file, logLoadErrors);
                if (!includeInCompatible && !IsSaveFileCompatible(docRoot))
                {
                    continue;
                }
                if (docRoot == null)
                {
                    saveInfos.Add(new CampaignMode.SaveInfo(
                        FilePath: file,
                        SaveTime: Option.None,
                        SubmarineName: "",
                        RespawnMode: RespawnMode.None,
                        EnabledContentPackageNames: ImmutableArray<string>.Empty));
                }
                else
                {
                    List<string> enabledContentPackageNames = new List<string>();

                    //backwards compatibility
                    string enabledContentPackagePathsStr = docRoot.GetAttributeStringUnrestricted("selectedcontentpackages", string.Empty);
                    foreach (string packagePath in enabledContentPackagePathsStr.Split('|'))
                    {
                        if (string.IsNullOrEmpty(packagePath)) { continue; }
                        //change paths to names
                        string fileName = Path.GetFileNameWithoutExtension(packagePath);
                        if (fileName == "filelist")
                        { 
                            enabledContentPackageNames.Add(Path.GetFileName(Path.GetDirectoryName(packagePath) ?? ""));
                        }
                        else
                        {
                            enabledContentPackageNames.Add(fileName);
                        }
                    }

                    string enabledContentPackageNamesStr = docRoot.GetAttributeStringUnrestricted("selectedcontentpackagenames", string.Empty);
                    //split on pipes, excluding pipes preceded by \
                    foreach (string packageName in Regex.Split(enabledContentPackageNamesStr, @"(?<!(?<!\\)*\\)\|"))
                    {
                        if (string.IsNullOrEmpty(packageName)) { continue; }                        
                        enabledContentPackageNames.Add(packageName.Replace(@"\|", "|"));                        
                    }

                    saveInfos.Add(new CampaignMode.SaveInfo(
                        FilePath: file,
                        SaveTime: docRoot.GetAttributeDateTime("savetime"),
                        SubmarineName: docRoot.GetAttributeStringUnrestricted("submarine", ""),
                        RespawnMode: docRoot.GetAttributeEnum("respawnmode", RespawnMode.None),
                        EnabledContentPackageNames: enabledContentPackageNames.ToImmutableArray()));
                }
            }
            
            return saveInfos;
        }

        public static string CreateSavePath(SaveType saveType, string fileName = "Save_Default")
        {
            fileName = ToolBox.RemoveInvalidFileNameChars(fileName);

            string folder = GetSaveFolder(saveType);
            if (fileName == "Save_Default")
            {
                fileName = TextManager.Get("SaveFile.DefaultName").Value;
                if (fileName.Length == 0) fileName = "Save";
            }

            if (!Directory.Exists(folder))
            {
                DebugConsole.Log("Save folder \"" + folder + "\" not found. Created new folder");
                Directory.CreateDirectory(folder, catchUnauthorizedAccessExceptions: true);
            }

            string extension = ".save";
            string pathWithoutExtension = Path.Combine(folder, fileName);

            if (!File.Exists(pathWithoutExtension + extension))
            {
                return pathWithoutExtension + extension;
            }

            int i = 0;
            while (File.Exists(pathWithoutExtension + " " + i + extension))
            {
                i++;
            }

            return pathWithoutExtension + " " + i + extension;
        }

        public static void CompressStringToFile(string fileName, string value)
        {
            // A.
            // Convert the string to its byte representation.
            byte[] b = Encoding.UTF8.GetBytes(value);

            // B.
            // Use GZipStream to write compressed bytes to target file.
            using FileStream f2 = File.Open(fileName, System.IO.FileMode.Create)
                ?? throw new Exception($"Failed to create file \"{fileName}\"");;
            using GZipStream gz = new GZipStream(f2, CompressionMode.Compress, false);
            gz.Write(b, 0, b.Length);
        }

        private static void CompressFile(string sDir, string sRelativePath, GZipStream zipStream)
        {
            //Compress file name
            if (sRelativePath.Length > 255)
            {
                throw new Exception(
                    $"Failed to compress \"{sDir}\" (file name length > 255).");
            }
            // File name length is encoded as a 32-bit little endian integer here
            zipStream.WriteByte((byte)sRelativePath.Length);
            zipStream.WriteByte(0);
            zipStream.WriteByte(0);
            zipStream.WriteByte(0);
            // File name content is encoded as little-endian UTF-16
            var strBytes = Encoding.Unicode.GetBytes(sRelativePath.CleanUpPathCrossPlatform(correctFilenameCase: false));
            zipStream.Write(strBytes, 0, strBytes.Length);

            //Compress file content
            byte[] bytes = File.ReadAllBytes(Path.Combine(sDir, sRelativePath));
            zipStream.Write(BitConverter.GetBytes(bytes.Length), 0, sizeof(int));
            zipStream.Write(bytes, 0, bytes.Length);
        }

        public static void CompressDirectory(string sInDir, string sOutFile)
        {
            IEnumerable<string> sFiles = Directory.GetFiles(sInDir, "*.*", System.IO.SearchOption.AllDirectories);
            int iDirLen = sInDir[^1] == Path.DirectorySeparatorChar ? sInDir.Length : sInDir.Length + 1;

            using var outFile = File.Open(sOutFile, System.IO.FileMode.Create, System.IO.FileAccess.Write)
                ?? throw new Exception($"Failed to create file \"{sOutFile}\"");
            using GZipStream str = new GZipStream(outFile, CompressionMode.Compress);
            foreach (string sFilePath in sFiles)
            {
                string sRelativePath = sFilePath.Substring(iDirLen);
                CompressFile(sInDir, sRelativePath, str);
            }
        }


        public static System.IO.Stream DecompressFileToStream(string fileName)
        {
            using FileStream originalFileStream = File.Open(fileName, System.IO.FileMode.Open, System.IO.FileAccess.Read)
                ?? throw new Exception($"Failed to open file \"{fileName}\"");
            System.IO.MemoryStream streamToReturn = new System.IO.MemoryStream();

            using GZipStream gzipStream = new GZipStream(originalFileStream, CompressionMode.Decompress);
            gzipStream.CopyTo(streamToReturn);
            
            streamToReturn.Position = 0;
            return streamToReturn;
        }

        private static bool IsExtractionPathValid(string rootDir, string fileDir)
        {
            string getFullPath(string dir)
                => (string.IsNullOrEmpty(dir)
                        ? Directory.GetCurrentDirectory()
                        : Path.GetFullPath(dir))
                    .CleanUpPathCrossPlatform(correctFilenameCase: false);
            
            string rootDirFull = getFullPath(rootDir);
            string fileDirFull = getFullPath(fileDir);

            return fileDirFull.StartsWith(rootDirFull, StringComparison.OrdinalIgnoreCase);
        }

        private static bool DecompressFile(System.IO.BinaryReader reader, [NotNullWhen(returnValue: true)]out string? fileName, [NotNullWhen(returnValue: true)]out byte[]? fileContent)
        {
            fileName = null;
            fileContent = null;

            if (reader.PeekChar() < 0) { return false; }
            
            //Decompress file name
            int nameLen = reader.ReadInt32();
            if (nameLen > 255)
            {
                throw new Exception(
                    $"Failed to decompress (file name length > 255). The file may be corrupted.");
            }

            byte[] strBytes = reader.ReadBytes(nameLen * sizeof(char));
            string sFileName = Encoding.Unicode.GetString(strBytes)
                .Replace('\\', '/');

            fileName = sFileName;

            //Decompress file content
            int contentLen = reader.ReadInt32();
            fileContent = reader.ReadBytes(contentLen);

            return true;
        }

        public static void DecompressToDirectory(string sCompressedFile, string sDir)
        {
            DebugConsole.Log("Decompressing " + sCompressedFile + " to " + sDir + "...");
            const int maxRetries = 4;
            for (int i = 0; i <= maxRetries; i++)
            {
                try
                {
                    using var memStream = DecompressFileToStream(sCompressedFile);
                    using var reader = new System.IO.BinaryReader(memStream);
                    while (DecompressFile(reader, out var fileName, out var contentBytes))
                    {
                        string sFilePath = Path.Combine(sDir, fileName);
                        string sFinalDir = Path.GetDirectoryName(sFilePath) ?? "";

                        if (!IsExtractionPathValid(sDir, sFinalDir))
                        {
                            throw new InvalidOperationException(
                                $"Error extracting \"{fileName}\": cannot be extracted to parent directory");
                        }

                        Directory.CreateDirectory(sFinalDir);
                        using var outFile = File.Open(sFilePath, System.IO.FileMode.Create, System.IO.FileAccess.Write)
                            ?? throw new Exception($"Failed to create file \"{sFilePath}\"");
                        outFile.Write(contentBytes, 0, contentBytes.Length);
                    }
                    break;
                }
                catch (System.IO.IOException e)
                {
                    if (i >= maxRetries || !File.Exists(sCompressedFile)) { throw; }
                    DebugConsole.NewMessage("Failed decompress file \"" + sCompressedFile + "\" {" + e.Message + "}, retrying in 250 ms...", Color.Red);
                    Thread.Sleep(250);
                }
            }
        }

        public static IEnumerable<string> EnumerateContainedFiles(string sCompressedFile)
        {
            const int maxRetries = 4;
            HashSet<string> paths = new HashSet<string>();
            for (int i = 0; i <= maxRetries; i++)
            {
                try
                {
                    paths.Clear();
                    using var memStream = DecompressFileToStream(sCompressedFile);
                    using var reader = new System.IO.BinaryReader(memStream);
                    while (DecompressFile(reader, out var fileName, out _))
                    {
                        paths.Add(fileName);
                    }
                    break;
                }
                catch (System.IO.IOException e)
                {
                    if (i >= maxRetries || !File.Exists(sCompressedFile)) { throw; }

                    DebugConsole.NewMessage(
                        $"Failed to decompress file \"{sCompressedFile}\" for enumeration {{{e.Message}}}, retrying in 250 ms...",
                        Color.Red);
                    Thread.Sleep(250);
                }
            }

            return paths;
        }

        /// <summary>
        /// Extracts the save file (including all the subs in it) to a temporary folder and returns the game session document.
        /// If you only need the gamesession doc, use <see cref="ExtractGameSessionRootElementFromSaveFile"/> instead.
        /// </summary>
        /// <param name="savePath"></param>
        /// <returns></returns>
        public static XDocument? DecompressSaveAndLoadGameSessionDoc(string savePath)
        {
            DebugConsole.Log("Loading game session doc: " + savePath);
            try
            {
                DecompressToDirectory(savePath, TempPath);
            }
            catch (Exception e)
            {
                DebugConsole.ThrowError("Error decompressing " + savePath, e);
                return null;
            }
            return XMLExtensions.TryLoadXml(Path.Combine(TempPath, "gamesession.xml"));
        }

        /// <summary>
        /// Extract *only* the root element of the gamesession.xml file in the given save.
        /// For performance reasons, none of its child elements are returned.
        /// </summary>
        public static XElement? ExtractGameSessionRootElementFromSaveFile(string savePath, bool logLoadErrors = true)
        {
            const int maxRetries = 4;
            for (int i = 0; i <= maxRetries; i++)
            {
                try
                {
                    using var memStream = DecompressFileToStream(savePath);
                    using var reader = new System.IO.BinaryReader(memStream);
                    while (DecompressFile(reader, out var fileName, out var fileContent))
                    {
                        if (fileName != GameSessionFileName) { continue; }

                        // Found the file! Here's a quick byte-wise parser to find the root element
                        int tagOpenerStartIndex = -1;
                        for (int j = 0; j < fileContent.Length; j++)
                        {
                            if (fileContent[j] == '<')
                            {
                                // Found a tag opener: return null if we had already found one
                                if (tagOpenerStartIndex >= 0) { return null; }
                                tagOpenerStartIndex = j;
                            }
                            else if (j > 0 && fileContent[j] == '?' && fileContent[j - 1] == '<')
                            {
                                // Found the XML version element, skip this
                                tagOpenerStartIndex = -1;
                            }
                            else if (fileContent[j] == '>')
                            {
                                // Found a tag closer, if we know where the tag opener is then we've found the root element
                                if (tagOpenerStartIndex < 0) { continue; }

                                string elemStr = Encoding.UTF8.GetString(fileContent.AsSpan()[tagOpenerStartIndex..j]) + "/>";
                                try
                                {
                                    return XElement.Parse(elemStr);
                                }
                                catch (Exception e)
                                {
                                    DebugConsole.NewMessage(
                                        $"Failed to parse gamesession root in \"{savePath}\": {{{e.Message}}}.",
                                        Color.Red);
                                    // Parsing the element failed! Return null instead of crashing here
                                    return null;
                                }
                            }
                        }
                    }
                    break;
                }
                catch (System.IO.IOException e)
                {
                    if (i >= maxRetries || !File.Exists(savePath)) { throw; }

                    DebugConsole.NewMessage(
                        $"Failed to decompress file \"{savePath}\" for root extraction ({e.Message}), retrying in 250 ms...",
                        Color.Red);
                    Thread.Sleep(250);
                }
                catch (System.IO.InvalidDataException e)
                {
                    if (logLoadErrors)
                    {
                        DebugConsole.ThrowError($"Failed to decompress file \"{savePath}\" for root extraction.", e);
                    }
                    return null;
                }
            }
            return null;
        }

        public static void DeleteDownloadedSubs()
        {
            if (Directory.Exists(SubmarineDownloadFolder)) 
            { 
                ClearFolder(SubmarineDownloadFolder);
            }
        }

        public static void CleanUnnecessarySaveFiles()
        {
            if (Directory.Exists(CampaignDownloadFolder)) 
            { 
                ClearFolder(CampaignDownloadFolder);
                Directory.Delete(CampaignDownloadFolder);
            }
            if (Directory.Exists(TempPath)) 
            { 
                ClearFolder(TempPath);
                Directory.Delete(TempPath);
            }
        }

        public static void ClearFolder(string folderName, string[]? ignoredFileNames = null)
        {
            DirectoryInfo dir = new DirectoryInfo(folderName);

            foreach (FileInfo fi in dir.GetFiles())
            {
                if (ignoredFileNames != null)
                {
                    bool ignore = false;
                    foreach (string ignoredFile in ignoredFileNames)
                    {
                        if (Path.GetFileName(fi.FullName).Equals(Path.GetFileName(ignoredFile)))
                        {
                            ignore = true;
                            break;
                        }
                    }
                    if (ignore) continue;
                }
                fi.IsReadOnly = false;
                fi.Delete();
            }

            foreach (DirectoryInfo di in dir.GetDirectories())
            {
                ClearFolder(di.FullName, ignoredFileNames);
                const int maxRetries = 4;
                for (int i = 0; i <= maxRetries; i++)
                {
                    try
                    {
                        di.Delete();
                        break;
                    }
                    catch (System.IO.IOException)
                    {
                        if (i >= maxRetries) { throw; }
                        Thread.Sleep(250);
                    }
                }
            }
        }

#region Backup saves

        [NetworkSerialize]
        public readonly record struct BackupIndexData(uint Index,
                                                      Identifier LocationNameIdentifier,
                                                      int LocationNameFormatIndex,
                                                      Identifier LocationType,
                                                      LevelData.LevelType LevelType,
                                                      SerializableDateTime SaveTime) : INetSerializableStruct;

        public static string FormatBackupExtension(uint index) => string.Format(BackupExtensionFormat, index);
        public static string FormatBackupCharacterDataExtension(uint index) => string.Format(BackupCharacterDataFormat, index);

        public static void BackupSave(string savePath)
        {
            string path = Path.GetDirectoryName(savePath) ?? "";
            string fileName = Path.GetFileNameWithoutExtension(savePath);
            string characterDataSavePath = MultiPlayerCampaign.GetCharacterDataSavePath(savePath);
            string characterDataFileName = Path.GetFileNameWithoutExtension(characterDataSavePath);

            ImmutableArray<BackupIndexData> indexData = GetIndexData(path, fileName);

            uint freeIndex = GetFreeIndex(indexData);

            string newBackupPath = Path.Combine(path, $".{fileName}{FormatBackupExtension(freeIndex)}");
            string newCharacterDataBackupPath = Path.Combine(path, $".{characterDataFileName}{FormatBackupCharacterDataExtension(freeIndex)}");

            try
            {
                BackupFile(savePath, newBackupPath);
                if (File.Exists(characterDataSavePath))
                {
                    BackupFile(characterDataSavePath, newCharacterDataBackupPath);
                }
            }
            catch (Exception e)
            {
                DebugConsole.ThrowError("Failed to create a backup of the save file.", e);
            }

            static uint GetFreeIndex(IEnumerable<BackupIndexData> indexData)
            {
                if (!indexData.Any()) { return 0; }

                if (indexData.Count() < MaxBackupCount)
                {
                    uint highestIndex = indexData.Max(static b => b.Index);
                    uint nextIndex = highestIndex + 1;

                    if (indexData.Any(b => b.Index == nextIndex))
                    {
                        for (uint i = 0; i < MaxBackupCount; i++)
                        {
                            if (indexData.All(b => b.Index != i)) { return i; }
                        }

                        // this should theoretically never happen
                        throw new InvalidOperationException("Failed to find a free index for the backup.");
                    }

                    return nextIndex;
                }

                BackupIndexData oldestBackup = indexData.OrderBy(static b => b.SaveTime).First();
                return oldestBackup.Index;
            }

            static void BackupFile(string sourcePath, string destPath)
            {
                // Overwriting a file that is marked as hidden will cause an exception.
                DeleteIfExists(destPath);
                System.IO.File.Copy(sourcePath, destPath, overwrite: true);
                SetHidden(destPath);
            }
        }

        public static void DeleteIfExists(string filePath)
        {
            if (System.IO.File.Exists(filePath))
            {
                System.IO.File.Delete(filePath);
            }
        }

        public static ImmutableArray<BackupIndexData> GetIndexData(string fullPath)
        {
            string path = Path.GetDirectoryName(fullPath) ?? "";
            string fileName = Path.GetFileNameWithoutExtension(fullPath);
            return GetIndexData(path, fileName);
        }

        private static readonly System.IO.EnumerationOptions BackupEnumerationOptions = new System.IO.EnumerationOptions
        {
            MatchType = System.IO.MatchType.Win32,
            AttributesToSkip = System.IO.FileAttributes.System,
            IgnoreInaccessible = true
        };

        private static string[] GetBackupPaths(string path, string baseName)
        {
            try
            {
                return System.IO.Directory.GetFiles(path, $".{baseName}{FullBackupExtension}*", BackupEnumerationOptions);
            }
            catch (Exception e)
            {
                DebugConsole.ThrowError("Failed to get backup paths.", e);
            }
            return Array.Empty<string>();
        }

        public static bool TryGetBackupIndexFromFileName(string filePath, out uint index)
        {
            string extension = Path.GetExtension(filePath);
            if (extension.Length < BackupExtension.Length)
            {
                DebugConsole.ThrowError($"The file name \"{filePath}\" does not have a valid backup extension.");
                index = 0;
                return false;
            }

            string indexStr = extension[BackupExtension.Length..];
            bool result = uint.TryParse(indexStr, out index);
            if (!result)
            {
                DebugConsole.ThrowError($"Failed to parse the backup index from the file name \"{filePath}\".");
            }

            return result;
        }

        private static ImmutableArray<BackupIndexData> GetIndexData(string path, string baseName)
        {
            var builder = ImmutableArray.CreateBuilder<BackupIndexData>();

            string[] foundBackups = GetBackupPaths(path, baseName);

            foreach (string backupPath in foundBackups)
            {
                if (!TryGetBackupIndexFromFileName(backupPath, out uint index)) { continue; }

                var gameSession = ExtractGameSessionRootElementFromSaveFile(backupPath, logLoadErrors: false);

                if (gameSession is null)
                {
                    DebugConsole.AddWarning($"Failed to load gamesession root from \"{backupPath}\". Skipping this backup.");
                    continue;
                }

                SerializableDateTime saveTime =
                    gameSession.GetAttributeDateTime("savetime")
                               .Fallback(SerializableDateTime.FromUtcUnixTime(0L));

                Identifier locationNameIdentifier = gameSession.GetAttributeIdentifier("currentlocation", Identifier.Empty);
                int locationNameFormatIndex = gameSession.GetAttributeInt("currentlocationnameformatindex", -1);
                Identifier locationType = gameSession.GetAttributeIdentifier("locationtype", Identifier.Empty);

                LevelData.LevelType levelType = gameSession.GetAttributeEnum("nextleveltype", LevelData.LevelType.LocationConnection);

                builder.Add(new BackupIndexData(index, locationNameIdentifier, locationNameFormatIndex, locationType, levelType, saveTime));
            }

            return builder.ToImmutable();
        }

        public static string GetBackupPath(string savePath, uint index)
        {
            string path = Path.GetDirectoryName(savePath) ?? "";
            string fileName = Path.GetFileNameWithoutExtension(savePath);
            return Path.Combine(path, $".{fileName}{FormatBackupExtension(index)}");
        }

        private static void SetHidden(string filePath)
        {
            try
            {
                System.IO.File.SetAttributes(filePath, System.IO.File.GetAttributes(filePath) | System.IO.FileAttributes.Hidden);
            }
            catch (Exception e)
            {
                DebugConsole.ThrowError("Failed to set the backup file as hidden.", e);
            }
        }
#endregion
    }
}
