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

namespace Evo.UI.Migration
{
    // Loading, previewing, applying and restoring an export in v2
    internal static partial class MigrationEngine
    {
        static readonly string[] KeptExtensions = { ".png", ".psd", ".tga", ".jpg", ".ttf", ".otf", ".asset", ".prefab" };

        #region Loading
        /// <summary>
        /// Reads an export and checks it before anything uses it: supported format, paths under Assets,
        /// unique GUIDs, and content that matches its checksum.
        /// </summary>
        public static MigrationManifest Load(string path)
        {
            var manifest = JsonUtility.FromJson<MigrationManifest>(File.ReadAllText(path));
            if (manifest == null || manifest.format != 1 || manifest.sourceVersion != "1" || manifest.targetVersion != "2" || manifest.changes == null)
                throw new InvalidDataException("This is not a supported Evo UI v1 → v2 migration export.");

            var guids = new HashSet<string>();
            foreach (MigrationChange change in manifest.changes)
            {
                CheckMigratedPath(change.path);
                if (!IsGuid(change.guid) || !guids.Add(change.guid))
                    throw new InvalidDataException("Invalid or duplicate asset GUID.");

                if (Hash(Convert.FromBase64String(change.content)) != change.afterHash)
                    throw new InvalidDataException("Export content does not match its checksum: " + change.path);

                foreach (MigrationGenerated generated in change.generated)
                {
                    SafePath(generated.path);
                    if (!generated.path.StartsWith(PresetsFolder + "/", StringComparison.Ordinal) || Path.GetExtension(generated.path) != ".prefab"
                        || !IsGuid(generated.guid) || !generated.meta.Contains("guid: " + generated.guid))
                        throw new InvalidDataException("Invalid generated prefab.");

                    Convert.FromBase64String(generated.content);
                }

                change.selected = change.HasChanges;
            }

            // Exports made before kept assets existed have none. Kept prefabs can bring the animation preset copies they need.
            manifest.kept ??= new List<MigrationGenerated>();
            foreach (MigrationGenerated asset in manifest.kept)
            {
                SafePath(asset.path);
                string extension = Path.GetExtension(asset.path).ToLowerInvariant();
                bool kept = asset.path.StartsWith(KeptFolder + "/", StringComparison.Ordinal) && KeptExtensions.Contains(extension);
                bool preset = asset.path.StartsWith(PresetsFolder + "/", StringComparison.Ordinal) && extension == ".prefab";
                if (!kept && !preset || !IsGuid(asset.guid) || !guids.Add(asset.guid) || !asset.meta.Contains("guid: " + asset.guid))
                    throw new InvalidDataException("Invalid kept v1 asset: " + asset.path);

                Convert.FromBase64String(asset.content);
            }

            return manifest;
        }

        static bool IsGuid(string value) => Regex.IsMatch(value ?? "", "^[a-f0-9]{32}$");
        #endregion

        #region Kept assets
        /// <summary>
        /// The kept v1 assets the project doesn't have yet. One whose GUID is already in the project, such as from a v1 sprite folder
        /// that wasn't deleted, stays where it is, and a different file at a kept asset's path is never overwritten.
        /// </summary>
        public static List<MigrationGenerated> MissingKeptAssets(MigrationManifest manifest)
        {
            return manifest.kept
                .Where(asset =>
                {
                    string existing = AssetDatabase.GUIDToAssetPath(asset.guid);
                    return (string.IsNullOrEmpty(existing) || !File.Exists(existing)) && !File.Exists(asset.path) && !File.Exists(asset.path + ".meta");
                })
                .ToList();
        }
        #endregion

        #region Preview
        /// <summary>
        /// Finds each change's asset by GUID, so moved assets still match, and compares it with the export.
        /// Reads only the listed files and never rescans the project.
        /// </summary>
        public static void Preview(MigrationManifest manifest)
        {
            foreach (MigrationChange change in manifest.changes)
            {
                change.resolvedPath = AssetDatabase.GUIDToAssetPath(change.guid);
                change.status = GetStatus(change);
            }
        }

        static ChangeStatus GetStatus(MigrationChange change)
        {
            if (string.IsNullOrEmpty(change.resolvedPath))
                return ChangeStatus.Missing;

            try { CheckMigratedPath(change.resolvedPath); }
            catch { return ChangeStatus.OutsideAssets; }

            if (!File.Exists(change.resolvedPath))
                return ChangeStatus.Missing;

            if (!change.HasChanges)
                return ChangeStatus.ReviewOnly;

            string hash = Hash(File.ReadAllBytes(change.resolvedPath));
            if (hash == change.afterHash)
                return ChangeStatus.Applied;

            if (hash != change.beforeHash)
                return ChangeStatus.Changed;

            return change.generated.Any(HasPresetConflict) ? ChangeStatus.PresetConflict : ChangeStatus.Ready;
        }

        // A preset copy may already exist from an earlier apply, but only with exactly the same content and GUID
        static bool HasPresetConflict(MigrationGenerated generated)
        {
            string existing = AssetDatabase.GUIDToAssetPath(generated.guid);
            if (!string.IsNullOrEmpty(existing) && existing != generated.path)
                return true;

            if (File.Exists(generated.path) && Hash(File.ReadAllBytes(generated.path)) != Hash(Convert.FromBase64String(generated.content)))
                return true;

            return File.Exists(generated.path + ".meta") && File.ReadAllText(generated.path + ".meta") != generated.meta;
        }

        /// <summary>
        /// What a status means, for the window and error messages.
        /// </summary>
        public static string Describe(ChangeStatus status) => status switch
        {
            ChangeStatus.ReviewOnly => "Review only",
            ChangeStatus.Ready => "Ready",
            ChangeStatus.Applied => "Already applied",
            ChangeStatus.Changed => "Changed since export",
            ChangeStatus.Missing => "Missing asset",
            ChangeStatus.OutsideAssets => "Outside Assets",
            ChangeStatus.PresetConflict => "Generated prefab conflict",
            _ => "Not checked"
        };
        #endregion

        #region Selection
        /// <summary>
        /// The changes an asset needs applied with it, such as the prefabs a scene uses. Only lists changes in the export.
        /// </summary>
        public static IEnumerable<MigrationChange> GetDependencies(MigrationManifest manifest, MigrationChange change)
        {
            return change.requires.Select(manifest.Find).Where(dependency => dependency != null);
        }

        /// <summary>
        /// The changes that need an asset applied with them, the reverse of GetDependencies.
        /// </summary>
        public static IEnumerable<MigrationChange> GetDependents(MigrationManifest manifest, MigrationChange change)
        {
            return manifest.changes.Where(other => other.requires.Contains(change.guid));
        }

        /// <summary>
        /// Why the selection can't be applied, or null if it can: a selected change isn't ready, or needs one that isn't selected.
        /// </summary>
        public static string SelectionIssue(MigrationManifest manifest)
        {
            foreach (MigrationChange change in manifest.changes.Where(change => change.selected && change.HasChanges && change.status != ChangeStatus.Applied))
            {
                if (change.status != ChangeStatus.Ready)
                    return $"{change.path}: {Describe(change.status)}";

                MigrationChange missing = GetDependencies(manifest, change).FirstOrDefault(dependency => !dependency.selected && dependency.status != ChangeStatus.Applied);
                if (missing != null)
                    return "Also select the dependency: " + missing.path;
            }

            return null;
        }

        /// <summary>
        /// Selects every ready change whose dependencies can be applied with it, so SelectionIssue is always null afterwards.
        /// </summary>
        public static void SelectReady(MigrationManifest manifest)
        {
            foreach (MigrationChange change in manifest.changes)
                change.selected = change.HasChanges && change.status == ChangeStatus.Ready;

            DeselectStranded(manifest);
        }

        /// <summary>
        /// Clears selected changes that need one that's neither selected nor applied.
        /// </summary>
        public static void DeselectStranded(MigrationManifest manifest)
        {
            // Dropping a change can strand the ones that need it, so repeat until nothing else drops
            for (bool changed = true; changed;)
            {
                changed = false;
                foreach (MigrationChange change in manifest.changes.Where(change => change.selected))
                {
                    if (GetDependencies(manifest, change).Any(dependency => !dependency.selected && dependency.status != ChangeStatus.Applied))
                    {
                        change.selected = false;
                        changed = true;
                    }
                }
            }
        }
        #endregion

        #region Apply
        /// <summary>
        /// Writes the selected ready changes, their preset copies and the missing kept v1 assets in one batch, backing up every file first, then refreshes Unity once.
        /// committed runs once every file is written, before the refresh that may reload the domain. Returns the backup folder.
        /// </summary>
        public static string Apply(MigrationManifest manifest, Action<string> committed = null)
        {
            if (DetectVersion() != InstalledVersion.V2)
                throw new InvalidOperationException("Applying needs Evo UI v2, and only v2, installed.");

            CheckSaved();
            Preview(manifest);

            string issue = SelectionIssue(manifest);
            if (issue != null)
                throw new InvalidOperationException(issue);

            MigrationChange[] chosen = manifest.changes.Where(change => change.selected && change.HasChanges && change.status == ChangeStatus.Ready).ToArray();
            List<MigrationGenerated> kept = MissingKeptAssets(manifest);
            if (chosen.Length == 0 && kept.Count == 0)
                throw new InvalidOperationException("No ready changes selected.");

            Dictionary<string, byte[]> writes = CollectWrites(chosen, kept);
            string backup = Path.Combine(StateDirectory, "Backups", DateTime.UtcNow.ToString("yyyyMMdd-HHmmss-fff"));
            BackupJournal journal = BackUp(writes, backup);

            // One disk transaction, then one refresh. Never load or reserialize every scene and prefab.
            SceneSetup[] setup = EditorSceneManager.GetSceneManagerSetup();
            AssetDatabase.DisallowAutoRefresh();
            EditorApplication.LockReloadAssemblies();
            bool editing = false;
            try
            {
                CloseAffectedScenes(writes.Keys);
                AssetDatabase.StartAssetEditing();
                editing = true;

                foreach (KeyValuePair<string, byte[]> write in writes)
                {
                    string full = SafePath(write.Key);
                    Directory.CreateDirectory(Path.GetDirectoryName(full));
                    File.WriteAllBytes(full, write.Value);
                }

                File.WriteAllText(Path.Combine(StateDirectory, "last-backup.txt"), backup);
                File.WriteAllText(Path.Combine(backup, "result.txt"), $"Applied {chosen.Length} assets and kept {kept.Count} v1 assets.\n"
                    + string.Join("\n", chosen.Select(change => change.resolvedPath).Concat(kept.Select(asset => asset.path))));
                committed?.Invoke(backup);
            }
            catch
            {
                RestoreFiles(journal);
                throw;
            }
            finally
            {
                if (editing)
                    AssetDatabase.StopAssetEditing();

                AssetDatabase.AllowAutoRefresh();
                EditorApplication.UnlockReloadAssemblies();
                AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
                if (setup.Length > 0)
                    EditorSceneManager.RestoreSceneManagerSetup(setup);
            }

            return backup;
        }

        // Each change's file plus its preset copies, and the kept v1 assets. Changes may share a preset, but only with identical content.
        static Dictionary<string, byte[]> CollectWrites(IEnumerable<MigrationChange> changes, IEnumerable<MigrationGenerated> kept)
        {
            var writes = new Dictionary<string, byte[]>(StringComparer.OrdinalIgnoreCase);
            foreach (MigrationChange change in changes)
            {
                writes.Add(change.resolvedPath, Convert.FromBase64String(change.content));
                foreach (MigrationGenerated generated in change.generated)
                {
                    AddShared(generated.path, Convert.FromBase64String(generated.content));
                    AddShared(generated.path + ".meta", Encoding.UTF8.GetBytes(generated.meta));
                }
            }

            foreach (MigrationGenerated asset in kept)
            {
                AddShared(asset.path, Convert.FromBase64String(asset.content));
                AddShared(asset.path + ".meta", Encoding.UTF8.GetBytes(asset.meta));
            }

            return writes;

            void AddShared(string path, byte[] bytes)
            {
                if (writes.TryGetValue(path, out byte[] existing) && Hash(existing) != Hash(bytes))
                    throw new InvalidDataException("Conflicting generated file: " + path);

                writes[path] = bytes;
            }
        }

        static BackupJournal BackUp(Dictionary<string, byte[]> writes, string backup)
        {
            Directory.CreateDirectory(backup);
            var journal = new BackupJournal();
            foreach (KeyValuePair<string, byte[]> write in writes)
            {
                string full = SafePath(write.Key);
                bool existed = File.Exists(full);
                string copy = Path.Combine(backup, journal.entries.Count.ToString("D5") + ".bak");
                if (existed)
                    File.Copy(full, copy);

                journal.entries.Add(new BackupEntry { path = write.Key, backup = copy, existed = existed, afterHash = Hash(write.Value) });
            }

            File.WriteAllText(Path.Combine(backup, "journal.json"), JsonUtility.ToJson(journal, true));
            return journal;
        }

        // Unity can't keep a scene open while its file changes underneath it. With every open scene affected, an empty one keeps the editor valid.
        static void CloseAffectedScenes(IEnumerable<string> paths)
        {
            var affected = new HashSet<string>(paths);
            Scene[] scenes = Enumerable.Range(0, SceneManager.sceneCount).Select(SceneManager.GetSceneAt).Where(scene => affected.Contains(scene.path)).ToArray();
            if (scenes.Length > 0 && scenes.Length == SceneManager.sceneCount)
                EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive);

            foreach (Scene scene in scenes)
            {
                if (!EditorSceneManager.CloseScene(scene, true))
                    throw new InvalidOperationException("Could not close the scene before migration: " + scene.path);
            }
        }
        #endregion

        #region Restore
        /// <summary>
        /// The latest apply's backup folder, or null.
        /// </summary>
        public static string LastBackup
        {
            get
            {
                string record = Path.Combine(StateDirectory, "last-backup.txt");
                return File.Exists(record) ? File.ReadAllText(record) : null;
            }
        }

        /// <summary>
        /// Puts back the files the latest apply overwrote. Refuses if any of them changed since, so later edits are never lost.
        /// </summary>
        public static void Restore()
        {
            CheckSaved();
            string backup = LastBackup ?? throw new InvalidOperationException("No migration backup is available.");
            var journal = JsonUtility.FromJson<BackupJournal>(File.ReadAllText(Path.Combine(backup, "journal.json")));
            foreach (BackupEntry entry in journal.entries)
            {
                string full = SafePath(entry.path);
                if (!File.Exists(full) || Hash(File.ReadAllBytes(full)) != entry.afterHash)
                    throw new InvalidOperationException("This file changed after migration; restore it manually from the backup: " + entry.path);
            }

            SceneSetup[] setup = EditorSceneManager.GetSceneManagerSetup();
            AssetDatabase.DisallowAutoRefresh();
            try
            {
                CloseAffectedScenes(journal.entries.Select(entry => entry.path));
                RestoreFiles(journal);
            }
            finally
            {
                AssetDatabase.AllowAutoRefresh();
                AssetDatabase.Refresh(ImportAssetOptions.ForceSynchronousImport);
                if (setup.Length > 0)
                    EditorSceneManager.RestoreSceneManagerSetup(setup);
            }
        }

        static void RestoreFiles(BackupJournal journal)
        {
            foreach (BackupEntry entry in journal.entries)
            {
                string full = SafePath(entry.path);
                if (entry.existed)
                    File.Copy(entry.backup, full, true);
                else if (File.Exists(full))
                    File.Delete(full);
            }
        }
        #endregion
    }
}
