using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;

namespace Evo.UI.Migration
{
    /// <summary>
    /// Automatic mode: export in v1, remove the v1 package, let the user import v2, then apply the export.
    /// The state lives in Library, so the hand-off survives compile errors, domain reloads and editor restarts.
    /// </summary>
    // Statics here and in the engine and window are editor session state that must survive entering Play Mode without a domain reload.
    // The attribute is public from Unity 6.5, and this standalone tool can't use the stand-in Evo/Shared has for older versions.
    [InitializeOnLoad]
#if UNITY_6000_5_OR_NEWER
    [Unity.Scripting.LifecycleManagement.NoAutoStaticsCleanup]
#endif
    internal static class MigrationAutomation
    {
        /// <summary>
        /// What automatic mode removes: the files and folders of a known Evo UI v1 release. Anything else in its folders is kept.
        /// </summary>
        internal sealed class RemovalPlan
        {
            public readonly List<string> roots = new();
            public readonly List<string> files = new();
            public readonly List<string> folders = new();
            public readonly List<string> kept = new();
        }

        // Stages saved in the state
        internal const string Removing = "Removing";
        internal const string AwaitingV2 = "AwaitingV2";
        internal const string Applied = "Applied";

        static bool busy;
        static bool watching;
        static double settleAt;

        static MigrationAutomation()
        {
            EditorApplication.delayCall += Resume;
        }

        #region State
        static string Folder => Path.Combine(MigrationEngine.StateDirectory, "Automatic");
        static string StatePath => Path.Combine(Folder, "state.json");

        public static bool Pending => File.Exists(StatePath);
        public static AutomaticState State => Pending ? JsonUtility.FromJson<AutomaticState>(File.ReadAllText(StatePath)) : null;

        // Written to a temporary file first, so an interrupted save never leaves a broken state behind
        static void Save(AutomaticState state)
        {
            Directory.CreateDirectory(Folder);
            string temp = StatePath + ".tmp";
            File.WriteAllText(temp, JsonUtility.ToJson(state, true));
            if (File.Exists(StatePath))
                File.Replace(temp, StatePath, null);
            else
                File.Move(temp, StatePath);
        }

        static void Clear()
        {
            if (File.Exists(StatePath))
                File.Delete(StatePath);
        }

        internal static string Full(string path) => Path.Combine(MigrationEngine.Root, path);
        static string Relative(string full) => Path.GetFullPath(full).Substring(MigrationEngine.Root.Length).TrimStart('\\', '/').Replace('\\', '/');
        #endregion

        #region Planning
        /// <summary>
        /// Plans removing Evo UI v1. Only assets whose GUIDs belong to a known v1 release go. Anything else in the package folders
        /// (user presets, generated config, other files, links) stays where it is. Throws if the installation can't be verified.
        /// </summary>
        public static RemovalPlan Plan()
        {
            if (MigrationEngine.DetectVersion() != InstalledVersion.V1)
                throw new InvalidOperationException("Automatic migration requires Evo UI v1 only.");

            var index = JsonUtility.FromJson<PackageIndex>(File.ReadAllText(MigrationEngine.ToolRoot + "/Editor/Resources/EvoMigration.Packages.json"));
            var known = new HashSet<string>(index.guids);
            var plan = new RemovalPlan();
            foreach (PackageRoot root in index.roots)
            {
                string asmdef = AssetDatabase.GUIDToAssetPath(root.assembly);
                if (string.IsNullOrEmpty(asmdef) || !File.Exists(Full(asmdef)))
                    continue;

                string folder = Path.GetDirectoryName(asmdef).Replace('\\', '/');
                MigrationEngine.SafePath(asmdef);
                if (!folder.StartsWith("Assets/", StringComparison.Ordinal) || !known.Contains(GuidOf(folder + ".meta") ?? ""))
                    throw new InvalidOperationException($"The Evo UI v1 {root.name} folder could not be verified: {folder}");

                plan.roots.Add(folder);
            }

            plan.roots.RemoveAll(root => plan.roots.Any(other => root.StartsWith(other + "/", StringComparison.Ordinal)));
            foreach (string root in plan.roots)
            {
                if (Scan(root, known, plan))
                    plan.folders.Add(root);
            }

            // A v1 marker left behind would turn the v2 install into a mixed one
            string marker = MigrationEngine.FindMarkerScripts("ProceduralRect").FirstOrDefault(path => !plan.files.Contains(path));
            if (marker != null || plan.roots.Count == 0)
                throw new InvalidOperationException("This Evo UI v1 installation is not recognized" + (marker != null ? ": " + marker : "."));

            return plan;
        }

        /// <summary>
        /// Adds a folder's known files to the plan, and its folders once everything inside them goes, deepest first.
        /// Returns true if the whole folder can go.
        /// </summary>
        internal static bool Scan(string folder, HashSet<string> known, RemovalPlan plan)
        {
            bool all = true;
            foreach (FileSystemInfo entry in new DirectoryInfo(Full(folder)).GetFileSystemInfos().OrderBy(entry => entry.Name, StringComparer.Ordinal))
            {
                string path = folder + "/" + entry.Name;
                bool directory = entry is DirectoryInfo;

                // A .meta goes with its asset, so only one left without an asset is checked on its own
                if (!directory && entry.Name.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))
                {
                    string asset = path.Substring(0, path.Length - ".meta".Length);
                    if (File.Exists(Full(asset)) || Directory.Exists(Full(asset)))
                        continue;

                    if (known.Contains(GuidOf(path) ?? ""))
                        plan.files.Add(path);
                    else
                    {
                        plan.kept.Add(path);
                        all = false;
                    }

                    continue;
                }

                string guid = (entry.Attributes & FileAttributes.ReparsePoint) == 0 ? GuidOf(path + ".meta") : null;
                if (guid == null || !known.Contains(guid))
                {
                    plan.kept.Add(path);
                    all = false;
                }
                else if (!directory)
                {
                    plan.files.Add(path);
                    plan.files.Add(path + ".meta");
                }
                else if (Scan(path, known, plan))
                    plan.folders.Add(path);
                else
                    all = false;
            }

            return all;
        }

        static string GuidOf(string meta)
        {
            string full = Full(meta);
            if (!File.Exists(full) || (File.GetAttributes(full) & FileAttributes.ReparsePoint) != 0)
                return null;

            Match match = Regex.Match(File.ReadAllText(full), "(?m)^guid: ([a-f0-9]{32})");
            return match.Success ? match.Groups[1].Value : null;
        }
        #endregion

        #region Removing v1
        /// <summary>
        /// Saves a copy of the export and moves Evo UI v1 into Library, so it can be put back until v2 is installed.
        /// keepRemoved keeps the removed files after a successful migration too, instead of deleting them.
        /// </summary>
        public static void Start(string exportPath, bool keepRemoved)
        {
            if (Pending)
                throw new InvalidOperationException("An automatic migration is already in progress.");

            MigrationEngine.CheckSaved();
            RemovalPlan plan = Plan();
            byte[] export = File.ReadAllBytes(exportPath);
            MigrationEngine.Load(exportPath);

            string stamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
            string run = Path.Combine(Folder, stamp);
            for (int i = 2; Directory.Exists(run); i++)
                run = Path.Combine(Folder, $"{stamp}-{i}");

            Directory.CreateDirectory(run);
            File.WriteAllBytes(Path.Combine(run, "migration.json"), export);

            var state = new AutomaticState
            {
                stage = Removing,
                export = Relative(Path.Combine(run, "migration.json")),
                exportHash = MigrationEngine.Hash(export),
                source = Path.GetFullPath(exportPath),
                removed = Relative(Path.Combine(run, "v1")),
                keepRemoved = keepRemoved,
                kept = plan.kept,
                folders = plan.folders,
                files = plan.files.Concat(plan.folders.Select(folder => folder + ".meta")).ToList(),
                scenes = EditorSceneManager.GetSceneManagerSetup()
                    .Select(scene => new AutomaticScene { path = scene.path, loaded = scene.isLoaded, active = scene.isActive })
                    .ToList()
            };

            busy = true;
            try
            {
                // Scenes stay closed while their scripts are missing, so they can't be resaved without them. They reopen after migrating.
                EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
                Save(state);
                Remove(state);
            }
            catch
            {
                if (State?.stage != AwaitingV2)
                    RestoreScenes(state);

                throw;
            }
            finally { busy = false; }
        }

        // Moves every planned file, or none: a failure puts back whatever was already moved
        static void Remove(AutomaticState state)
        {
            Exception error = null;
            AssetDatabase.DisallowAutoRefresh();
            EditorApplication.LockReloadAssemblies();
            try
            {
                var folderMetas = new HashSet<string>(state.folders.Select(folder => folder + ".meta"));
                for (int i = 0; i < state.files.Count; i++)
                {
                    if (!folderMetas.Contains(state.files[i]))
                        Move(state.files[i], Stored(state, i));
                }

                // A folder that can't be deleted, such as one another program holds open, simply stays, with its .meta
                foreach (string folder in state.folders)
                {
                    try
                    {
                        if (Directory.EnumerateFileSystemEntries(Full(folder)).Any())
                            continue;

                        Directory.Delete(Full(folder));
                    }
                    catch (IOException) { continue; }
                    catch (UnauthorizedAccessException) { continue; }

                    Move(folder + ".meta", Stored(state, state.files.IndexOf(folder + ".meta")));
                }

                // Saved before the refresh, since a synchronous reload must already see v1 as removed
                state.stage = AwaitingV2;
                Save(state);
            }
            catch (Exception exception)
            {
                try
                {
                    MoveBack(state);
                    Clear();
                    error = new InvalidOperationException("Evo UI v1 could not be removed, so nothing was changed. " + exception.Message, exception);
                }
                catch (Exception restoreError)
                {
                    Debug.LogException(restoreError);
                    error = new InvalidOperationException("Removing Evo UI v1 failed and some files could not be put back yet. "
                        + $"Restart Unity to finish restoring them (they are kept in {state.removed}). {exception.Message}", exception);
                }
            }
            finally
            {
                EditorApplication.UnlockReloadAssemblies();
                AssetDatabase.AllowAutoRefresh();
            }

            AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
            if (error != null)
                throw error;
        }

        // Stored flat and numbered by their position in state.files, so deep asset paths never exceed Windows path limits
        static string Stored(AutomaticState state, int index) => $"{state.removed}/{index:D5}";

        static void Move(string from, string to)
        {
            string target = Full(to);
            Directory.CreateDirectory(Path.GetDirectoryName(target));
            File.Move(Full(from), target);
        }

        static void MoveBack(AutomaticState state)
        {
            foreach (string folder in Enumerable.Reverse(state.folders))
                Directory.CreateDirectory(Full(folder));

            for (int i = 0; i < state.files.Count; i++)
            {
                if (File.Exists(Full(Stored(state, i))) && !File.Exists(Full(state.files[i])))
                    Move(Stored(state, i), state.files[i]);
            }

            string removed = Full(state.removed);
            if (Directory.Exists(removed) && !Directory.EnumerateFiles(removed, "*", SearchOption.AllDirectories).Any())
                Directory.Delete(removed, true);
        }
        #endregion

        #region While waiting
        /// <summary>
        /// Cancels automatic mode before v2 is installed and puts the removed v1 files back.
        /// </summary>
        public static void RestoreV1()
        {
            AutomaticState state = State;
            if (state == null || state.stage != AwaitingV2)
                throw new InvalidOperationException("There is no removed Evo UI v1 to restore.");

            if (MigrationEngine.HasV2)
                throw new InvalidOperationException("Evo UI v2 is already installed. Restoring v1 now would mix both versions.");

            string conflict = state.files.Where((file, i) => File.Exists(Full(Stored(state, i))) && File.Exists(Full(file))).FirstOrDefault();
            if (conflict != null)
                throw new InvalidOperationException("Evo UI v1 cannot be restored because this file already exists: " + conflict);

            busy = true;
            AssetDatabase.DisallowAutoRefresh();
            try
            {
                MoveBack(state);
                Clear();
            }
            finally
            {
                AssetDatabase.AllowAutoRefresh();
                busy = false;
            }

            AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
            RestoreScenes(state);
        }

        public static void SetKeepRemoved(bool keep)
        {
            AutomaticState state = State;
            if (state == null)
                return;

            state.keepRemoved = keep;
            Save(state);
        }

        /// <summary>
        /// Leaves v1 removed and hands the saved export to the manual workflow. Returns its full path.
        /// </summary>
        public static string Abandon()
        {
            AutomaticState state = State;
            Clear();
            return state == null ? null : Full(state.export);
        }
        #endregion

        #region Applying
        static void Resume()
        {
            AutomaticState state;
            try { state = State; }
            catch (Exception exception)
            {
                Debug.LogException(exception);
                return;
            }

            if (state == null || busy)
                return;

            if (state.stage == Removing)
            {
                // The editor stopped while removing v1. Put back whatever was moved; the user can export again.
                try
                {
                    MoveBack(state);
                    Clear();
                }
                catch (Exception exception)
                {
                    Debug.LogException(exception);
                    return;
                }

                AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
                RestoreScenes(state);
                Debug.LogWarning("Evo UI migration: removing v1 was interrupted, so its files were restored. Export again to continue.");
                return;
            }

            Watch();
        }

        /// <summary>
        /// Checks the state again once the editor has been idle for a second, as after a package import.
        /// </summary>
        internal static void Watch()
        {
            settleAt = EditorApplication.timeSinceStartup + 1;
            if (watching)
                return;

            watching = true;
            EditorApplication.update += Tick;
        }

        // Waits until imports, compilation and Play Mode are idle, so a package import is complete before applying
        static void Tick()
        {
            double now = EditorApplication.timeSinceStartup;
            if (busy || EditorApplication.isCompiling || EditorApplication.isUpdating || EditorApplication.isPlayingOrWillChangePlaymode)
                settleAt = Math.Max(settleAt, now + 1);

            if (now < settleAt)
                return;

            watching = false;
            EditorApplication.update -= Tick;

            AutomaticState state;
            try { state = State; }
            catch (Exception exception)
            {
                Debug.LogException(exception);
                return;
            }

            if (state?.stage == Applied)
                Finish(state);
            else if (state?.stage == AwaitingV2 && MigrationEngine.DetectVersion() == InstalledVersion.V2)
                ApplyNow(state);
        }

        static void ApplyNow(AutomaticState state)
        {
            string path = Full(state.export);
            busy = true;
            try
            {
                if (!File.Exists(path) || MigrationEngine.Hash(File.ReadAllBytes(path)) != state.exportHash)
                    throw new InvalidDataException("The saved migration data is missing or was modified: " + state.export);

                MigrationManifest manifest = MigrationEngine.Load(path);
                MigrationEngine.Preview(manifest);
                MigrationEngine.SelectReady(manifest);
                int count = manifest.changes.Count(change => change.selected);
                int kept = MigrationEngine.MissingKeptAssets(manifest).Count;

                if (count > 0 || kept > 0)
                    MigrationEngine.Apply(manifest, Commit);
                else
                    Commit(null);

                void Commit(string backup)
                {
                    state.stage = Applied;
                    state.backup = backup == null ? null : Relative(backup);
                    state.summary = Summarize(manifest, count, kept);
                    Save(state);
                }
            }
            catch (Exception exception)
            {
                Debug.LogException(exception);

                // Once committed, the files are written, and only the refresh or the scene restore failed
                if (State?.stage != Applied)
                {
                    busy = false;
                    Stop(state, path, exception);
                    return;
                }
            }
            finally { busy = false; }

            Watch();
        }

        static string Summarize(MigrationManifest manifest, int applied, int kept)
        {
            int skipped = manifest.changes.Count(change => change.HasChanges && !change.selected && change.status != ChangeStatus.Applied);
            int review = manifest.changes.Count(change => change.NeedsReview);

            string summary = applied == 0 ? "No changes needed applying." : applied == 1 ? "Applied 1 asset." : $"Applied {applied} assets.";
            if (kept > 0)
                summary += $" Kept {(kept == 1 ? "1 v1 asset" : $"{kept} v1 assets")} that v2 doesn't include in {MigrationEngine.KeptFolder}.";
            if (skipped > 0)
                summary += skipped == 1 ? " 1 asset was not applied; see its status in the migration window." : $" {skipped} assets were not applied; see their status in the migration window.";

            if (review > 0)
                summary += review == 1 ? " 1 asset has manual review notes." : $" {review} assets have manual review notes.";

            return summary;
        }

        static void Finish(AutomaticState state)
        {
            Clear();
            if (!state.keepRemoved)
                DeleteRemoved(state);

            RestoreScenes(state);

            string paths = (state.backup != null ? $" Backup: {state.backup}." : "") + (state.keepRemoved ? $" Removed v1 files: {state.removed}." : "");
            ShowResult(Full(state.export), "Automatic migration complete. " + state.summary + paths, false);
        }

        // Only ever deletes this run's own folder of removed v1 files
        static void DeleteRemoved(AutomaticState state)
        {
            string removed = Path.GetFullPath(Full(state.removed));
            if (Path.GetFileName(removed) != "v1" || !removed.StartsWith(Path.GetFullPath(Folder) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
                return;

            try
            {
                if (Directory.Exists(removed))
                    Directory.Delete(removed, true);
            }
            catch (Exception exception) { Debug.LogException(exception); }
        }

        static void Stop(AutomaticState state, string path, Exception exception)
        {
            Clear();
            RestoreScenes(state);
            ShowResult(path, $"Automatic migration stopped: {exception.Message} Review the changes and apply them yourself.", true);
        }

        static void ShowResult(string exportPath, string message, bool failed)
        {
            if (Application.isBatchMode)
            {
                Debug.Log("Evo UI migration: " + message);
                return;
            }

            try { MigrationWindow.ShowAutomaticResult(File.Exists(exportPath) ? exportPath : null, message, failed); }
            catch (Exception exception) { Debug.LogException(exception); }
        }

        static void RestoreScenes(AutomaticState state)
        {
            SceneSetup[] scenes = state.scenes
                .Where(scene => !string.IsNullOrEmpty(scene.path) && File.Exists(Full(scene.path)))
                .Select(scene => new SceneSetup { path = scene.path, isLoaded = scene.loaded, isActive = scene.active })
                .ToArray();

            if (scenes.Length == 0)
                return;

            // A scene that's gone may have been the active one, and Unity needs one loaded and active
            if (!scenes.Any(scene => scene.isActive && scene.isLoaded))
            {
                SceneSetup first = scenes.FirstOrDefault(scene => scene.isLoaded) ?? scenes[0];
                foreach (SceneSetup scene in scenes)
                    scene.isActive = false;

                first.isLoaded = true;
                first.isActive = true;
            }

            try
            {
                if (EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
                    EditorSceneManager.RestoreSceneManagerSetup(scenes);
            }
            catch (Exception exception) { Debug.LogException(exception); }
        }
        #endregion
    }

    // Package imports end here, so a pending automatic migration checks whether v2 has arrived
    internal sealed class MigrationImportWatcher : AssetPostprocessor
    {
        static void OnPostprocessAllAssets(string[] imported, string[] deleted, string[] moved, string[] movedFrom)
        {
            if (MigrationAutomation.Pending)
                MigrationAutomation.Watch();
        }
    }
}
