using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text.RegularExpressions;
using BepInEx.Configuration;
using BepInEx.Logging;
using Mono.Cecil;

namespace BepInEx.Bootstrap
{
	public abstract class BaseChainloader<TPlugin>
	{
		#region Contract

		protected virtual string ConsoleTitle => $"BepInEx {typeof(Paths).Assembly.GetName().Version} - {Process.GetCurrentProcess().ProcessName}";

		private bool _initialized = false;

		public Dictionary<string, PluginInfo> Plugins { get; } = new Dictionary<string, PluginInfo>();

		public List<string> DependencyErrors { get; } = new List<string>();

		public virtual void Initialize(string gameExePath = null)
		{
			if (_initialized)
				throw new InvalidOperationException("Chainloader cannot be initialized multiple times");

			// Set vitals
			if (gameExePath != null)
			{
				// Checking for null allows a more advanced initialization workflow, where the Paths class has been initialized before calling Chainloader.Initialize
				// This is used by Preloader to use environment variables, for example
				Paths.SetExecutablePath(gameExePath);
			}

			InitializeLoggers();

			if (!Directory.Exists(Paths.PluginPath))
				Directory.CreateDirectory(Paths.PluginPath);

			if (!Directory.Exists(Paths.PatcherPluginPath))
				Directory.CreateDirectory(Paths.PatcherPluginPath);

			_initialized = true;

			Logger.LogMessage("Chainloader initialized");
		}

		protected virtual void InitializeLoggers()
		{
			if (ConsoleManager.ConfigConsoleEnabled.Value && !ConsoleManager.ConsoleActive)
				ConsoleManager.CreateConsole();

			if (ConsoleManager.ConsoleActive)
			{
				if (!Logger.Listeners.Any(x => x is ConsoleLogListener))
					Logger.Listeners.Add(new ConsoleLogListener());

				ConsoleManager.SetConsoleTitle(ConsoleTitle);
			}

			if (ConfigDiskLogging.Value)
				Logger.Listeners.Add(new DiskLogListener("LogOutput.log", ConfigDiskConsoleDisplayedLevel.Value, ConfigDiskAppend.Value));

			if (!TraceLogSource.IsListening)
				Logger.Sources.Add(TraceLogSource.CreateSource());
		}

		protected virtual IList<PluginInfo> DiscoverPlugins()
		{
			var pluginsToLoad = TypeLoader.FindPluginTypes(Paths.PluginPath, ToPluginInfo, HasBepinPlugins, "chainloader");

			return pluginsToLoad.SelectMany(p => p.Value).ToList();
		}

		protected virtual IList<PluginInfo> ModifyLoadOrder(IList<PluginInfo> plugins)
		{
			// We use a sorted dictionary to ensure consistent load order
			var dependencyDict = new SortedDictionary<string, IEnumerable<string>>(StringComparer.InvariantCultureIgnoreCase);
			var pluginsByGuid = new Dictionary<string, PluginInfo>();

			foreach (var pluginInfoGroup in plugins.GroupBy(info => info.Metadata.GUID))
			{
				var alreadyLoaded = false;
				foreach (var pluginInfo in pluginInfoGroup.OrderByDescending(x => x.Metadata.Version))
				{
					if (alreadyLoaded)
					{
						Logger.LogWarning($"Skipping because a newer version exists [{pluginInfo.Metadata.Name} {pluginInfo.Metadata.Version}]");
						continue;
					}

					alreadyLoaded = true;

					// Perform checks that will prevent loading plugins in this run
					var filters = pluginInfo.Processes.ToList();
					bool invalidProcessName = filters.Count != 0 && filters.All(x => !string.Equals(x.ProcessName.Replace(".exe", ""), Paths.ProcessName, StringComparison.InvariantCultureIgnoreCase));

					if (invalidProcessName)
					{
						Logger.LogWarning($"Skipping because of process filters [{pluginInfo.Metadata.Name} {pluginInfo.Metadata.Version}]");
						continue;
					}

					dependencyDict[pluginInfo.Metadata.GUID] = pluginInfo.Dependencies.Select(d => d.DependencyGUID);
					pluginsByGuid[pluginInfo.Metadata.GUID] = pluginInfo;
				}
			}

			foreach (var pluginInfo in pluginsByGuid.Values.ToList())
			{
				if (pluginInfo.Incompatibilities.Any(incompatibility => pluginsByGuid.ContainsKey(incompatibility.IncompatibilityGUID)))
				{
					pluginsByGuid.Remove(pluginInfo.Metadata.GUID);
					dependencyDict.Remove(pluginInfo.Metadata.GUID);

					var incompatiblePlugins = pluginInfo.Incompatibilities.Select(x => x.IncompatibilityGUID).Where(x => pluginsByGuid.ContainsKey(x)).ToArray();
					string message = $@"Could not load [{pluginInfo.Metadata.Name}] because it is incompatible with: {string.Join(", ", incompatiblePlugins)}";
					DependencyErrors.Add(message);
					Logger.LogError(message);
				}
				else if (PluginTargetsWrongBepin(pluginInfo))
				{
					string message = $@"Plugin [{pluginInfo.Metadata.Name}] targets a wrong version of BepInEx ({pluginInfo.TargettedBepInExVersion}) and might not work until you update";
					DependencyErrors.Add(message);
					Logger.LogWarning(message);
				}
			}

			var emptyDependencies = new string[0];

			// Sort plugins by their dependencies.
			// Give missing dependencies no dependencies of its own, which will cause missing plugins to be first in the resulting list.
			var sortedPlugins = Utility.TopologicalSort(dependencyDict.Keys, x => dependencyDict.TryGetValue(x, out var deps) ? deps : emptyDependencies).ToList();

			return sortedPlugins.Where(pluginsByGuid.ContainsKey).Select(x => pluginsByGuid[x]).ToList();
		}

		public virtual void Execute()
		{
			try
			{
				var plugins = DiscoverPlugins();

				Logger.LogInfo($"{plugins.Count} plugins to load");

				var sortedPlugins = ModifyLoadOrder(plugins);

				var invalidPlugins = new HashSet<string>();
				var processedPlugins = new Dictionary<string, Version>();
				var loadedAssemblies = new Dictionary<string, Assembly>();

				foreach (var plugin in sortedPlugins)
				{
					var dependsOnInvalidPlugin = false;
					var missingDependencies = new List<BepInDependency>();
					foreach (var dependency in plugin.Dependencies)
					{
						// If the depenency wasn't already processed, it's missing altogether
						bool depenencyExists = processedPlugins.TryGetValue(dependency.DependencyGUID, out var pluginVersion);
						if (!depenencyExists || pluginVersion < dependency.MinimumVersion)
						{
							// If the dependency is hard, collect it into a list to show
							if ((dependency.Flags & BepInDependency.DependencyFlags.HardDependency) != 0)
								missingDependencies.Add(dependency);
							continue;
						}

						// If the dependency is invalid (e.g. has missing depedencies), report that to the user
						if (invalidPlugins.Contains(dependency.DependencyGUID))
						{
							dependsOnInvalidPlugin = true;
							break;
						}
					}

					processedPlugins.Add(plugin.Metadata.GUID, plugin.Metadata.Version);

					if (dependsOnInvalidPlugin)
					{
						string message = $"Skipping [{plugin.Metadata.Name}] because it has a dependency that was not loaded. See previous errors for details.";
						DependencyErrors.Add(message);
						Logger.LogWarning(message);
						continue;
					}

					if (missingDependencies.Count != 0)
					{
						bool IsEmptyVersion(Version v) => v.Major == 0 && v.Minor == 0 && v.Build <= 0 && v.Revision <= 0;

						string message = $@"Could not load [{plugin.Metadata.Name}] because it has missing dependencies: {
								string.Join(", ", missingDependencies.Select(s => IsEmptyVersion(s.MinimumVersion) ? s.DependencyGUID : $"{s.DependencyGUID} (v{s.MinimumVersion} or newer)").ToArray())
							}";
						DependencyErrors.Add(message);
						Logger.LogError(message);

						invalidPlugins.Add(plugin.Metadata.GUID);
						continue;
					}

					try
					{
						Logger.LogInfo($"Loading [{plugin.Metadata.Name} {plugin.Metadata.Version}]");

						if (!loadedAssemblies.TryGetValue(plugin.Location, out var ass))
							loadedAssemblies[plugin.Location] = ass = Assembly.LoadFile(plugin.Location);

						Plugins[plugin.Metadata.GUID] = plugin;
						plugin.Instance = LoadPlugin(plugin, ass);

						//_plugins.Add((TPlugin)plugin.Instance);
					}
					catch (Exception ex)
					{
						invalidPlugins.Add(plugin.Metadata.GUID);
						Plugins.Remove(plugin.Metadata.GUID);

						Logger.LogError($"Error loading [{plugin.Metadata.Name}] : {ex.Message}");
						if (ex is ReflectionTypeLoadException re)
							Logger.LogDebug(TypeLoader.TypeLoadExceptionToString(re));
						else
							Logger.LogDebug(ex);
					}
				}
			}
			catch (Exception ex)
			{
				Logger.LogError("Error occurred starting the game");
				Logger.LogDebug(ex);
			}

			Logger.LogMessage("Chainloader startup complete");
		}

		public abstract TPlugin LoadPlugin(PluginInfo pluginInfo, Assembly pluginAssembly);

		#endregion

		private static Regex allowedGuidRegex { get; } = new Regex(@"^[a-zA-Z0-9\._\-]+$");

		public static PluginInfo ToPluginInfo(TypeDefinition type, string assemblyLocation)
		{
			if (type.IsInterface || type.IsAbstract)
				return null;

			try
			{
				if (!type.IsSubtypeOf(typeof(TPlugin)))
					return null;
			}
			catch (AssemblyResolutionException)
			{
				// Can happen if this type inherits a type from an assembly that can't be found. Safe to assume it's not a plugin.
				return null;
			}

			var metadata = BepInPlugin.FromCecilType(type);

			// Perform checks that will prevent the plugin from being loaded in ALL cases
			if (metadata == null)
			{
				Logger.LogWarning($"Skipping over type [{type.FullName}] as no metadata attribute is specified");
				return null;
			}

			if (string.IsNullOrEmpty(metadata.GUID) || !allowedGuidRegex.IsMatch(metadata.GUID))
			{
				Logger.LogWarning($"Skipping type [{type.FullName}] because its GUID [{metadata.GUID}] is of an illegal format.");
				return null;
			}

			if (metadata.Version == null)
			{
				Logger.LogWarning($"Skipping type [{type.FullName}] because its version is invalid.");
				return null;
			}

			if (metadata.Name == null)
			{
				Logger.LogWarning($"Skipping type [{type.FullName}] because its name is null.");
				return null;
			}

			var filters = BepInProcess.FromCecilType(type);
			var dependencies = BepInDependency.FromCecilType(type);
			var incompatibilities = BepInIncompatibility.FromCecilType(type);

			var bepinVersion = type.Module.AssemblyReferences.FirstOrDefault(reference => reference.Name == "BepInEx.Core")?.Version ?? new Version();

			return new PluginInfo
			{
				Metadata = metadata,
				Processes = filters,
				Dependencies = dependencies,
				Incompatibilities = incompatibilities,
				TypeName = type.FullName,
				TargettedBepInExVersion = bepinVersion,
				Location = assemblyLocation
			};
		}

		protected static readonly string CurrentAssemblyName = Assembly.GetExecutingAssembly().GetName().Name;
		protected static readonly Version CurrentAssemblyVersion = Assembly.GetExecutingAssembly().GetName().Version;

		protected static bool HasBepinPlugins(AssemblyDefinition ass)
		{
			if (ass.MainModule.AssemblyReferences.All(r => r.Name != CurrentAssemblyName))
				return false;
			if (ass.MainModule.GetTypeReferences().All(r => r.FullName != typeof(TPlugin).FullName))
				return false;

			return true;
		}

		protected static bool PluginTargetsWrongBepin(PluginInfo pluginInfo)
		{
			var pluginTarget = pluginInfo.TargettedBepInExVersion;
			// X.X.X.x - compare normally. x.x.x.X - nightly build number, ignore
			if (pluginTarget.Major != CurrentAssemblyVersion.Major) return true;
			if (pluginTarget.Minor > CurrentAssemblyVersion.Minor) return true;
			if (pluginTarget.Minor < CurrentAssemblyVersion.Minor) return false;
			return pluginTarget.Build > CurrentAssemblyVersion.Build;
		}

		#region Config

		private static readonly ConfigEntry<bool> ConfigDiskAppend = ConfigFile.CoreConfig.Bind(
			"Logging.Disk", "AppendLog",
			false,
			"Appends to the log file instead of overwriting, on game startup.");

		private static readonly ConfigEntry<bool> ConfigDiskLogging = ConfigFile.CoreConfig.Bind(
			"Logging.Disk", "Enabled",
			true,
			"Enables writing log messages to disk.");

		private static readonly ConfigEntry<LogLevel> ConfigDiskConsoleDisplayedLevel = ConfigFile.CoreConfig.Bind(
			"Logging.Disk", "DisplayedLogLevel",
			LogLevel.Info,
			"Only displays the specified log level and above in the console output.");

		#endregion
	}
}
