#nullable enable
using Barotrauma.Extensions;
using Barotrauma.Steam;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using System.Xml.Linq;
using System.Xml;
using Barotrauma.IO;
using Steamworks.Data;

namespace Barotrauma
{
    public abstract class ContentPackage
    {
        public readonly record struct LoadError(string Message, Exception? Exception)
        {
            public override string ToString()
                => Message
                   + (Exception is { StackTrace: var stackTrace }
                       ? '\n' + stackTrace.CleanupStackTrace()
                       : string.Empty);
        }

        public static readonly Version MinimumHashCompatibleVersion = new Version(1, 1, 0, 0);
        
        public const string LocalModsDir = "LocalMods";
        public static readonly string WorkshopModsDir = Barotrauma.IO.Path.Combine(
            SaveUtil.DefaultSaveFolder,
            "WorkshopMods",
            "Installed");

        public const string FileListFileName = "filelist.xml";
        public const string DefaultModVersion = "1.0.0";

        public string Name { get; private set; }
        public readonly ImmutableArray<string> AltNames;
        public string Path { get; private set; }
        public string Dir => Barotrauma.IO.Path.GetDirectoryName(Path) ?? "";
        public readonly Option<ContentPackageId> UgcId;

        public readonly Version GameVersion;
        public readonly string ModVersion;

        public enum UgcStatus
        {
            NotFetched = 0,
            Fetching = 1,
            Fetched = 2,
            Unavailable = 3
        }

        public UgcStatus UgcItemStatus { get; private set; }

        /// <summary>
        /// Ugc item (workshop item) data that this content package corresponds to. Needs to be fetched with <see cref="TryFetchUgcItem(Action)"/>.
        /// You can also check <see cref="UgcItemStatus"/> to see if the item is available or not.
        /// </summary>
        public Option<Steamworks.Ugc.Item> UgcItem
        {
            get;
            private set;
        }

        public Md5Hash Hash { get; private set; }
        public readonly Option<SerializableDateTime> InstallTime;

        public ImmutableArray<ContentFile> Files { get; private set; }
        
        /// <summary>
        /// Errors that occurred when loading this content package.
        /// Currently, all errors are considered fatal and the game
        /// will refuse to load a content package that has any errors.
        /// </summary>
        public ImmutableArray<LoadError> FatalLoadErrors { get; private set; }

        /// <summary>
        /// An error that occurred when trying to enable this mod.
        /// This field doesn't directly affect whether or not this mod
        /// can be enabled, but if it's been set to anything other than
        /// Option.None then the game has already refused to enable it
        /// at least once.
        /// </summary>
        public Option<ContentPackageManager.LoadProgress.Error> EnableError { get; private set; }
            = Option.None;

        private readonly HashSet<PublishedFileId> missingDependencies = new HashSet<PublishedFileId>();
        /// <summary>
        /// An error caused by missing dependencies (Workshop items required by the package).
        /// Can be safe to ignore.
        /// </summary>
        public IEnumerable<PublishedFileId> MissingDependencies => missingDependencies;

        public bool HasAnyErrors => FatalLoadErrors.Length > 0 || EnableError.IsSome() || missingDependencies.Any();

        public async Task<bool> IsUpToDate()
        {
            if (!UgcId.TryUnwrap(out var ugcId)) { return true; }
            if (ugcId is not SteamWorkshopId steamWorkshopId) { return true; }
            if (!InstallTime.TryUnwrap(out var installTime)) { return true; }
            
            Option<Steamworks.Ugc.Item> itemOption = await SteamManager.Workshop.GetItem(steamWorkshopId.Value);
            if (!itemOption.TryUnwrap(out var item)) { return true; }
            return item.LatestUpdateTime <= installTime.ToUtcValue();
        }

        public int Index => ContentPackageManager.EnabledPackages.IndexOf(this);

        /// <summary>
        /// Does the content package include some content that needs to match between all players in multiplayer. 
        /// </summary>
        public bool HasMultiplayerSyncedContent { get; }

        protected ContentPackage(XDocument doc, string path)
        {
            using var errorCatcher = DebugConsole.ErrorCatcher.Create();
            
            Path = path.CleanUpPathCrossPlatform();
            XElement rootElement = doc.Root ?? throw new NullReferenceException("XML document is invalid: root element is null.");

            Name = rootElement.GetAttributeString("name", "").Trim();
            AltNames = rootElement.GetAttributeStringArray("altnames", Array.Empty<string>())
                .Select(n => n.Trim()).ToImmutableArray();
            UInt64 steamWorkshopId = rootElement.GetAttributeUInt64("steamworkshopid", 0);

            if (Name.IsNullOrWhiteSpace() && AltNames.Any())
            {
                Name = AltNames.First();
            }

            UgcId = steamWorkshopId != 0
                ? Option<ContentPackageId>.Some(new SteamWorkshopId(steamWorkshopId))
                : Option<ContentPackageId>.None();

            GameVersion = rootElement.GetAttributeVersion("gameversion", GameMain.Version);
            ModVersion = rootElement.GetAttributeString("modversion", DefaultModVersion);
            InstallTime = rootElement.GetAttributeDateTime("installtime");

            var fileResults = rootElement.Elements()
                .Where(e => !ContentFile.IsLegacyContentType(e, this, logWarning: true))
                .Select(e => ContentFile.CreateFromXElement(this, e))
                .ToArray();

            Files = fileResults
                .Successes()
                .ToImmutableArray();

            FatalLoadErrors = fileResults
                .Failures()
                .ToImmutableArray();

            AssertCondition(!string.IsNullOrEmpty(Name), $"{nameof(Name)} is null or empty");

            HasMultiplayerSyncedContent = Files.Any(f => !f.NotSyncedInMultiplayer);

            Hash = CalculateHash();
            var expectedHash = rootElement.GetAttributeString("expectedhash", "");
            if (HashMismatches(expectedHash))
            {
                FatalLoadErrors = FatalLoadErrors.Add(
                    new LoadError(
                        Message: $"Hash calculation returned {Hash.StringRepresentation}, expected {expectedHash}",
                        Exception: null
                    ));
            }

            FatalLoadErrors = FatalLoadErrors
                .Concat(errorCatcher.Errors.Select(err => new LoadError(err.Text, null)))
                .ToImmutableArray();
        }

        public bool HashMismatches(string expectedHash)
            => GameVersion >= MinimumHashCompatibleVersion &&
               !expectedHash.IsNullOrWhiteSpace() &&
               !expectedHash.Equals(Hash.StringRepresentation, StringComparison.OrdinalIgnoreCase);

        public IEnumerable<T> GetFiles<T>() where T : ContentFile => Files.OfType<T>();

        public IEnumerable<ContentFile> GetFiles(Type type)
            => !type.IsSubclassOf(typeof(ContentFile))
                ? throw new ArgumentException($"Type must be subclass of ContentFile, got {type.Name}")
                : Files.Where(f => f.GetType() == type || f.GetType().IsSubclassOf(type));

        public bool NameMatches(Identifier name)
            => Name == name || AltNames.Any(n => n == name);

        public bool NameMatches(string name)
            => NameMatches(name.ToIdentifier());
        
        public static Result<ContentPackage, Exception> TryLoad(string path)
        {
            var (success, failure) = Result<ContentPackage, Exception>.GetFactoryMethods();
            
            XDocument doc = XMLExtensions.TryLoadXml(path);

            try
            {
                return success(doc.Root.GetAttributeBool("corepackage", false)
                    ? new CorePackage(doc, path)
                    : new RegularPackage(doc, path));
            }
            catch (Exception e)
            {
                return failure(e.GetInnermost());
            }
        }

        public Md5Hash CalculateHash(bool logging = false, string? name = null, string? modVersion = null)
        {
            using IncrementalHash incrementalHash = IncrementalHash.CreateHash(HashAlgorithmName.MD5);
            
            if (logging)
            {
                DebugConsole.NewMessage("****************************** Calculating content package hash " + Name);
            }

            foreach (ContentFile file in Files)
            {
                try
                {
                    var hash = file.Hash;
                    if (logging)
                    {
                        DebugConsole.NewMessage("   " + file.Path + ": " + hash.StringRepresentation);
                    }                    
                    incrementalHash.AppendData(hash.ByteRepresentation);
                }

                catch (Exception e)
                {
                    DebugConsole.ThrowError($"Error while calculating the MD5 hash of the content package \"{Name}\" (file path: {Path}). The content package may be corrupted. You may want to delete or reinstall the package.", e);
                    break;
                }             
            }

            string selectedName = name ?? Name;
            if (!selectedName.IsNullOrEmpty())
            {
                incrementalHash.AppendData(Encoding.UTF8.GetBytes(selectedName));
            }
            incrementalHash.AppendData(Encoding.UTF8.GetBytes(modVersion ?? ModVersion));

            var md5Hash = Md5Hash.BytesAsHash(incrementalHash.GetHashAndReset());
            if (logging)
            {
                DebugConsole.NewMessage("****************************** Package hash: " + md5Hash.StringRepresentation);
            }

            return md5Hash;
        }

        protected void AssertCondition(bool condition, string errorMsg)
        {
            if (!condition)
            {
                FatalLoadErrors = FatalLoadErrors.Add(new LoadError(errorMsg, null));
            }
        }

        public void AddMissingDependency(PublishedFileId missingItemID)
        {
            missingDependencies.Add(missingItemID);
        }

        public void ClearMissingDependencies()
        {
            missingDependencies.Clear();
        }

        public void LoadFilesOfType<T>() where T : ContentFile
        {
            Files.Where(f => f is T).ForEach(f => f.LoadFile());
        }

        public void UnloadFilesOfType<T>() where T : ContentFile
        {
            Files.Where(f => f is T).ForEach(f => f.UnloadFile());
        }

        public enum LoadResult
        {
            Success,
            Failure
        }

        public LoadResult LoadContent()
        {
            foreach (var p in LoadContentEnumerable())
            {
                if (p.Result.IsFailure) { return LoadResult.Failure; }
            }
            return LoadResult.Success;
        }
        
        public IEnumerable<ContentPackageManager.LoadProgress> LoadContentEnumerable()
        {
            using var errorCatcher = DebugConsole.ErrorCatcher.Create();

            ContentFile[] getFilesToLoad(Predicate<ContentFile> predicate)
                => Files.Where(predicate.Invoke).ToArray()
#if DEBUG
                        //The game should be able to work just fine with a completely arbitrary file load order.
                        //To make sure we don't mess this up, debug builds randomize it so it has a higher chance
                        //of breaking anything that's not implemented correctly.
                        .Randomize(Rand.RandSync.Unsynced)
#endif
                    ;

            IEnumerable<ContentPackageManager.LoadProgress> loadFiles(ContentFile[] filesToLoad, int indexOffset)
            {
                for (int i = 0; i < filesToLoad.Length; i++)
                {
                    Exception? exception = null;

                    try
                    {
                        //do not allow exceptions thrown here to crash the game
                        filesToLoad[i].LoadFile();
                    }
                    catch (Exception e)
                    {
                        var innermost = e.GetInnermost();
                        DebugConsole.LogError($"Failed to load \"{filesToLoad[i].Path}\": {innermost.Message}\n{innermost.StackTrace}", contentPackage: this);
                        exception = e;
                    }
                    if (exception != null)
                    {
                        yield return ContentPackageManager.LoadProgress.Failure(exception);
                        yield break;
                    }

                    if (errorCatcher.Errors.Any())
                    {
                        yield return ContentPackageManager.LoadProgress.Failure(errorCatcher.Errors.Select(e => e.Text));
                        yield break;
                    }
                    yield return ContentPackageManager.LoadProgress.Progress((i + indexOffset) / (float)Files.Length);
                }
            }

            //Load the UI and text files first. This is to allow the game
            //to render the text in the loading screen as soon as possible.
            var priorityFiles = getFilesToLoad(f => f is UIStyleFile or TextFile);

            var remainder = getFilesToLoad(f => !priorityFiles.Contains(f));

            var loadEnumerable =
                loadFiles(priorityFiles, 0)
                    .Concat(loadFiles(remainder, priorityFiles.Length));
            
            foreach (var p in loadEnumerable)
            {
                if (p.Result.TryUnwrapFailure(out var failure))
                {
                    errorCatcher.Dispose();
                    UnloadContent();
                    EnableError = Option.Some(failure);
                    yield return p;
                    yield break;
                }
                yield return p;
            }
            errorCatcher.Dispose();
        }

        public void TryFetchUgcDescription(Action<string?> onFinished)
        {
            TryFetchUgcItem((Steamworks.Ugc.Item? item) =>
            {
                onFinished?.Invoke(item?.Description ?? string.Empty);
            });
        }

        public void TryFetchUgcChildren(Action<PublishedFileId[]?> onFinished)
        {
            TryFetchUgcItem((Steamworks.Ugc.Item? item) =>
            {
                onFinished?.Invoke(item?.Children ?? Array.Empty<PublishedFileId>());
            });
        }

        private void TryFetchUgcItem(Action<Steamworks.Ugc.Item?> onFinished)
        {
            switch (UgcItemStatus)
            {
                case UgcStatus.NotFetched:
                    TryFetchUgcItem(onFinished: () =>
                    {
                        if (UgcItemStatus == UgcStatus.Fetched &&
                            UgcItem.TryUnwrap(out var cachedItem))
                        {
                            onFinished?.Invoke(cachedItem);
                        }
                    });
                    break;
                case UgcStatus.Fetched when UgcItem.TryUnwrap(out var cachedItem):
                    onFinished?.Invoke(cachedItem);
                    break;
                default:
                    onFinished?.Invoke(null);
                    break;
            }
        }

        /// <summary>
        /// Attempts to fetch the UgcItem (workshop item) data from Steamworks, and if successful, caches it in <see cref="UgcItem"/>.
        /// </summary>
        /// <param name="onFinished">Triggers when the query finishes or fails (or immediately if the item has been already cached)</param>
        public void TryFetchUgcItem(Action onFinished)
        {
            if (UgcItemStatus != UgcStatus.NotFetched)
            {
                onFinished?.Invoke();
            }
            if (!UgcId.TryUnwrap(out var ugcId) || ugcId is not SteamWorkshopId workshopId) 
            {
                UgcItemStatus = UgcStatus.Unavailable;
                return;
            }
            
            UgcItemStatus = UgcStatus.Fetching;
            TaskPool.Add($"PrepareToShow{UgcId}Info", SteamManager.Workshop.GetItem(workshopId.Value),
                task =>
                {
                    if (!task.TryGetResult(out Option<Steamworks.Ugc.Item> itemOption) || !itemOption.TryUnwrap(out var item))
                    {
                        UgcItemStatus = UgcStatus.Unavailable;
                        return;
                    }
                    UgcItem = Option<Steamworks.Ugc.Item>.Some(item);
                    UgcItemStatus = UgcStatus.Fetched;
                    onFinished?.Invoke();
                });
        }

        public void UnloadContent()
        {
            Files.ForEach(f => f.UnloadFile());
        }

        public void ReloadSubsAndItemAssemblies()
        {
            XDocument doc = XMLExtensions.TryLoadXml(Path);
            List<ContentFile> newFileList = new List<ContentFile>();
            XElement rootElement = doc.Root ?? throw new NullReferenceException("XML document is invalid: root element is null.");
            
            var fileResults = rootElement.Elements()
                .Where(e => !ContentFile.IsLegacyContentType(e, this, logWarning: true))
                .Select(e => ContentFile.CreateFromXElement(this, e))
                .ToArray();

            foreach (var file in fileResults.Successes())
            {
                if (file is BaseSubFile or ItemAssemblyFile)
                {
                    newFileList.Add(file);
                }
                else
                {
                    var existingFile = Files.FirstOrDefault(f => f.Path == file.Path);
                    newFileList.Add(existingFile ?? file);
                }
            }

            UnloadFilesOfType<BaseSubFile>();
            UnloadFilesOfType<ItemAssemblyFile>();
            Files = newFileList.ToImmutableArray();
            Hash = CalculateHash();
            LoadFilesOfType<BaseSubFile>();
            LoadFilesOfType<ItemAssemblyFile>();
        }

        public static bool PathAllowedAsLocalModFile(string path)
        {
#if DEBUG
            if (GameMain.VanillaContent.Files.Any(f => f.Path == path))
            {
                //file is in vanilla package, this is allowed
                return true;
            }
#endif

            while (true)
            {
                string temp = Barotrauma.IO.Path.GetDirectoryName(path) ?? "";
                if (string.IsNullOrEmpty(temp)) { break; }
                path = temp;
            }
            return path == LocalModsDir;
        }

        public void LogErrors()
        {
            if (!FatalLoadErrors.Any())
            {
                return;
            }

            DebugConsole.AddWarning(
                $"The following errors occurred while loading the content package \"{Name}\". The package might not work correctly.\n" +
                string.Join('\n', FatalLoadErrors.Select(errorToStr)),
                this);

            static string errorToStr(LoadError error)
                => error.ToString();
        }

        public bool TryRenameLocal(string newName)
        {
            if (!ContentPackageManager.LocalPackages.Contains(this)) { return false; }

            if (newName.IsNullOrWhiteSpace())
            {
                DebugConsole.ThrowError($"New name is blank!");
                return false;
            }

            string newDir = IO.Path.Combine(IO.Path.GetFullPath(LocalModsDir), File.SanitizeName(newName));
            if (ContentPackageManager.LocalPackages.Any(lp => lp.NameMatches(newName)) || Directory.Exists(newDir))
            {
                DebugConsole.ThrowError($"A local package with the name or directory \"{newName}\" already exists!");
                return false;
            }

            XDocument doc = XMLExtensions.TryLoadXml(Path);
            doc.Root!.SetAttributeValue("name", newName);
            using (IO.XmlWriter writer = IO.XmlWriter.Create(Path, new XmlWriterSettings { Indent = true }))
            {
                doc.WriteTo(writer);
                writer.Flush();
            }

            Directory.Move(Dir, newDir);
            return true;
        }

        public bool TryDeleteLocal() => ContentPackageManager.LocalPackages.Contains(this) && Directory.TryDelete(Dir);

        public bool TryCreateLocalFromWorkshop()
        {
            if (!ContentPackageManager.WorkshopPackages.Contains(this)) { return false; }

            string newDir = IO.Path.Combine(IO.Path.GetFullPath(LocalModsDir), File.SanitizeName(Name));
            if (ContentPackageManager.LocalPackages.Any(lp => lp.NameMatches(Name)) || Directory.Exists(newDir))
            {
                DebugConsole.ThrowError($"A local package with the name or directory \"{Name}\" already exists!");
                return false;
            }

            Directory.Copy(Dir, newDir);
            return true;
        }
    }
}