using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using UnityEditor;
using UnityEditor.Compilation;
using UnityEngine;

namespace Evo.UI.Migration
{
    /// <summary>
    /// One run of the analysis helper: a small .NET program (Source~/Helper) shipped as a .bytes resource, so Unity doesn't
    /// import it as a plugin. It runs on the editor's own .NET runtime and Roslyn, reads the project, and writes an export
    /// to Library without changing any asset. Nothing here blocks, so poll a run from the editor loop.
    /// </summary>
    internal sealed class AnalysisRun : IDisposable
    {
        const string ProgressPrefix = "EVO_PROGRESS|";
        const string RunPrefix = "Analysis-";
        static readonly string[] SerializedExtensions = { ".asset", ".anim", ".controller", ".overrideController" };

        // Package source, never user content
        static readonly string[] PackageAssemblies = { "Evo.UI", "Evo", "Evo.Tween", "Evo.Localization" };

        readonly Process process;
        readonly StringBuilder errors = new();
        readonly object progressLock = new();
        string pendingProgress;

        /// <summary>
        /// Where the helper writes the export, in its own Library/EvoUIMigration/Analysis-* folder.
        /// </summary>
        public string OutputPath { get; }

        /// <summary>
        /// Share of the input files analyzed, with the last few percent left for dependencies and writing the export.
        /// </summary>
        public int Percent { get; private set; }

        public string Status { get; private set; } = "Preparing analysis…";
        public bool HasExited => process.HasExited;

        AnalysisRun(Process process, string outputPath)
        {
            this.process = process;
            OutputPath = outputPath;

            // Both arrive on other threads
            process.ErrorDataReceived += (_, evt) =>
            {
                if (evt.Data != null)
                    lock (errors) errors.AppendLine(evt.Data);
            };
            process.OutputDataReceived += (_, evt) =>
            {
                if (evt.Data != null && evt.Data.StartsWith(ProgressPrefix, StringComparison.Ordinal))
                    lock (progressLock) pendingProgress = evt.Data;
            };

            process.BeginErrorReadLine();
            process.BeginOutputReadLine();
        }

        /// <summary>
        /// Starts analyzing what the options select. Collecting Unity's compilation data takes a moment on the main thread;
        /// the rest runs in the helper's process. Needs Evo UI v1 installed and everything saved.
        /// </summary>
        public static AnalysisRun Start(AnalysisOptions options)
        {
            if (MigrationEngine.DetectVersion() != InstalledVersion.V1)
                throw new InvalidOperationException("Analysis needs Evo UI v1, and only v1, installed.");

            if (!options.HasAssetTypes)
                throw new InvalidOperationException("Choose at least one asset type to analyze.");

            MigrationEngine.CheckSaved();

            string folder = (options.folder ?? "").Replace('\\', '/').TrimEnd('/');
            if (folder != "Assets" && !folder.StartsWith("Assets/", StringComparison.Ordinal))
                throw new InvalidOperationException("Choose a folder under Assets to analyze.");

            if (!Directory.Exists(Path.GetDirectoryName(MigrationEngine.SafePath(folder + "/scope-check"))))
                throw new DirectoryNotFoundException("The folder to analyze doesn't exist: " + folder);

            var (dotnet, roslyn) = FindDotNet();
            string tool = MigrationEngine.ToolRoot;
            string run = Path.Combine(MigrationEngine.StateDirectory, RunPrefix + DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fff"));
            Directory.CreateDirectory(run);
            try
            {
                var request = new MigrationRequest
                {
                    root = MigrationEngine.Root,
                    catalog = Path.GetFullPath(tool + "/Editor/Resources/EvoMigration.Catalog.json"),
                    output = Path.Combine(run, "migration.json"),
                    assets = CollectAssets(options, folder, tool),
                    assemblies = options.scripts ? CollectAssemblies() : Array.Empty<MigrationAssembly>(),
                    legacyCodePage = options.legacyCodePage
                };

                string requestPath = Path.Combine(run, "request.json");
                File.WriteAllText(requestPath, JsonUtility.ToJson(request, true));

                // Built for .NET 6 and rolls forward to the runtime the editor has, such as .NET 8 or 10 from Unity 6.6
                string helper = Path.Combine(run, "EvoMigration.Helper.dll");
                File.Copy(tool + "/Editor/Resources/EvoMigration.Helper.bytes", helper);
                File.WriteAllText(Path.Combine(run, "EvoMigration.Helper.runtimeconfig.json"),
                    "{\"runtimeOptions\":{\"tfm\":\"net6.0\",\"rollForward\":\"Major\",\"framework\":{\"name\":\"Microsoft.NETCore.App\",\"version\":\"6.0.0\"}}}");

                var start = new ProcessStartInfo(dotnet, $"{Quote(helper)} {Quote(requestPath)} {Quote(roslyn)}")
                {
                    UseShellExecute = false,
                    CreateNoWindow = true,
                    RedirectStandardError = true,
                    RedirectStandardOutput = true,
                    WorkingDirectory = MigrationEngine.Root
                };

                return new AnalysisRun(Process.Start(start), request.output);
            }
            catch
            {
                DeleteRun(run);
                throw;
            }
        }

        // Unity 6.0 to 6.5 ship the .NET runtime and Roslyn on their own. From 6.6 they come with a .NET SDK, which holds Roslyn in its own folder.
        static (string dotnet, string roslyn) FindDotNet()
        {
            string data = EditorApplication.applicationContentsPath;
            string executable = Application.platform == RuntimePlatform.WindowsEditor ? "dotnet.exe" : "dotnet";
            string dotnet = new[] { Path.Combine(data, "NetCoreRuntime", executable), Path.Combine(data, "DotNetSdk", executable) }.FirstOrDefault(File.Exists);

            string roslyn = Path.Combine(data, "DotNetSdkRoslyn");
            string sdks = Path.Combine(data, "DotNetSdk", "sdk");
            if (!HasRoslyn(roslyn) && Directory.Exists(sdks))
            {
                roslyn = Directory.GetDirectories(sdks)
                    .OrderByDescending(sdk => Version.TryParse(Path.GetFileName(sdk).Split('-')[0], out Version version) ? version : new Version())
                    .Select(sdk => Path.Combine(sdk, "Roslyn", "bincore"))
                    .FirstOrDefault(HasRoslyn);
            }

            if (dotnet == null || !HasRoslyn(roslyn))
                throw new InvalidOperationException("This Unity editor doesn't include the .NET runtime and Roslyn compiler the analysis needs. Use Unity 6.0 or later to export.");

            return (dotnet, roslyn);
        }

        static bool HasRoslyn(string folder) => folder != null && File.Exists(Path.Combine(folder, "Microsoft.CodeAnalysis.CSharp.dll"));

        // The chosen asset types in the folder, minus Evo's package folders, this tool, and files written by an earlier migration
        static string[] CollectAssets(AnalysisOptions options, string folder, string tool)
        {
            string[] packageRoots = AssetDatabase.FindAssets("t:AssemblyDefinitionAsset")
                .Select(AssetDatabase.GUIDToAssetPath)
                .Where(path => PackageAssemblies.Contains(Path.GetFileNameWithoutExtension(path)))
                .Select(path => Path.GetDirectoryName(path).Replace('\\', '/') + "/")
                .ToArray();

            return AssetDatabase.GetAllAssetPaths()
                .Where(path => path.StartsWith(folder + "/", StringComparison.Ordinal)
                    && !path.StartsWith(tool + "/", StringComparison.Ordinal)
                    && !path.StartsWith(MigrationEngine.PresetsFolder + "/", StringComparison.Ordinal)
                    && !path.StartsWith(MigrationEngine.KeptFolder + "/", StringComparison.Ordinal)
                    && !packageRoots.Any(path.StartsWith)
                    && IsChosenType(path, options))
                .OrderBy(path => path, StringComparer.Ordinal)
                .ToArray();
        }

        static bool IsChosenType(string path, AnalysisOptions options)
        {
            return (options.prefabs && path.EndsWith(".prefab"))
                || (options.scenes && path.EndsWith(".unity"))
                || (options.scripts && path.EndsWith(".cs"))
                || (options.otherAssets && SerializedExtensions.Contains(Path.GetExtension(path)));
        }

        // Scripts are resolved against the same symbols Unity compiles them with. The player's assemblies reach the code the editor's
        // symbols leave inactive, like #if !UNITY_EDITOR branches. They reference the editor's builds of other assemblies, which exist.
        static MigrationAssembly[] CollectAssemblies()
        {
            Assembly[] editor = CompilationPipeline.GetAssemblies(AssembliesType.Editor);
            var outputs = editor.GroupBy(assembly => assembly.name).ToDictionary(group => group.Key, group => Path.GetFullPath(group.First().outputPath));

            return editor.Select(assembly => Describe(assembly, false))
                .Concat(CompilationPipeline.GetAssemblies(AssembliesType.PlayerWithoutTestAssemblies).Select(assembly => Describe(assembly, true)))
                .ToArray();

            MigrationAssembly Describe(Assembly assembly, bool player) => new MigrationAssembly
            {
                name = assembly.name,
                player = player,
                files = assembly.sourceFiles,
                defines = assembly.defines,
                references = assembly.compiledAssemblyReferences
                    .Concat(assembly.assemblyReferences.Select(reference => outputs.TryGetValue(reference.name, out string output) ? output : Path.GetFullPath(reference.outputPath)))
                    .Distinct()
                    .ToArray()
            };
        }

        static string Quote(string value) => "\"" + value.Replace("\"", "\\\"") + "\"";

        /// <summary>
        /// Takes the latest progress the helper reported. Returns true if there was any.
        /// </summary>
        public bool UpdateProgress()
        {
            string line;
            lock (progressLock)
            {
                line = pendingProgress;
                pendingProgress = null;
            }

            // EVO_PROGRESS|percent|message
            string[] parts = line?.Split(new[] { '|' }, 3);
            if (parts == null || parts.Length != 3 || !int.TryParse(parts[1], out int percent))
                return false;

            Percent = percent;
            Status = parts[2];
            return true;
        }

        /// <summary>
        /// Waits for the helper to exit and loads its export. Throws with the helper's errors if it failed.
        /// </summary>
        public MigrationManifest LoadResult()
        {
            // Also waits for the redirected output to finish
            process.WaitForExit();
            if (process.ExitCode != 0)
            {
                string details;
                lock (errors) details = errors.ToString().Trim();
                throw new InvalidOperationException("Analysis failed. " + details);
            }

            return MigrationEngine.Load(OutputPath);
        }

        /// <summary>
        /// Stops the helper. Its unfinished export is never loaded, and nothing in the project changes.
        /// </summary>
        public void Cancel()
        {
            try
            {
                if (!process.HasExited)
                    process.Kill();
            }
            catch (InvalidOperationException) { }
        }

        public void Dispose()
        {
            Cancel();
            process.Dispose();
        }

        #region Cleanup
        // Each run copies the helper and writes its request and export, tens of MB in a big project,
        // so only the run whose export is in use is kept

        /// <summary>
        /// Stops the helper and deletes this run's folder, for a run that was canceled or failed.
        /// </summary>
        public void Discard()
        {
            Dispose();
            DeleteRun(Path.GetDirectoryName(OutputPath));
        }

        /// <summary>
        /// Deletes every earlier analysis run except the one that wrote keepOutputPath.
        /// </summary>
        public static void DeleteOtherRuns(string keepOutputPath)
        {
            if (!Directory.Exists(MigrationEngine.StateDirectory))
                return;

            string keep = Path.GetFullPath(Path.GetDirectoryName(keepOutputPath));
            foreach (string run in Directory.GetDirectories(MigrationEngine.StateDirectory, RunPrefix + "*"))
            {
                if (!string.Equals(Path.GetFullPath(run), keep, StringComparison.OrdinalIgnoreCase))
                    DeleteRun(run);
            }
        }

        // Only ever deletes an analysis folder directly in Library/EvoUIMigration. Windows can keep the helper locked for a moment
        // after it's stopped, so a failed delete is tried again from the editor loop for a few seconds.
        static void DeleteRun(string run, int retries = 20)
        {
            string full = Path.GetFullPath(run);
            string state = Path.GetFullPath(MigrationEngine.StateDirectory);
            if (!Path.GetFileName(full).StartsWith(RunPrefix, StringComparison.Ordinal) || !string.Equals(Path.GetDirectoryName(full), state, StringComparison.OrdinalIgnoreCase))
                return;

            try
            {
                if (Directory.Exists(full))
                    Directory.Delete(full, true);
            }
            catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
            {
                if (retries == 0)
                {
                    UnityEngine.Debug.LogWarning($"Evo UI migration: couldn't delete the old analysis files in {full}. {exception.Message}");
                    return;
                }

                double retryAt = EditorApplication.timeSinceStartup + 0.25;
                EditorApplication.update += Retry;

                void Retry()
                {
                    if (EditorApplication.timeSinceStartup < retryAt)
                        return;

                    EditorApplication.update -= Retry;
                    DeleteRun(full, retries - 1);
                }
            }
        }
        #endregion
    }
}
