/*
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 * All rights reserved.
 *
 * Licensed under the Oculus SDK License Agreement (the "License");
 * you may not use the Oculus SDK except in compliance with the License,
 * which is provided at the time of installation or download, or which
 * otherwise accompanies this software in either electronic or hard copy form.
 *
 * You may obtain a copy of the License at
 *
 * https://developer.oculus.com/licenses/oculussdk/
 *
 * Unless required by applicable law or agreed to in writing, the Oculus SDK
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

using UnityEngine;
using UnityEditor;
using System.IO;
using System.Linq;
using System.Collections.Generic;
using System.Text;
using UnityEditor.PackageManager;

using PackageInfo = UnityEditor.PackageManager.PackageInfo;

namespace Oculus.Interaction.Editor
{
    [InitializeOnLoad]
    public static class PackageCleanup
    {
        private enum CleanupOperation
        {
            None,
            Delete,
            Move,
            StripTags,
        }

        private class CleanupInfo
        {
            public CleanupOperation Operation;
            public GUID AssetGuid;
            public GUID MoveToPathGuid;
        }

        private enum CleanupResult
        {
            None,
            Success,
            Cancel,
            Incomplete,
        }

        public const string PACKAGE_VERSION = "0.53.0";
        public const string DEPRECATED_TAG = "oculus_interaction_deprecated";
        public const string MOVED_TAG = "oculus_interaction_moved_";
        private const string MENU_NAME = "Oculus/Interaction/Clean Up Package";
        private const string AUTO_CLEANUP_KEY = "Oculus_Interaction_AutoCleanUp_" + PACKAGE_VERSION;

        private static bool AutoCleanup
        {
            get => PlayerPrefs.GetInt(AUTO_CLEANUP_KEY, 1) == 1;
            set => PlayerPrefs.SetInt(AUTO_CLEANUP_KEY, value ? 1 : 0);
        }

        static PackageCleanup()
        {
            EditorApplication.delayCall += HandleDelayCall;
        }

        [MenuItem(MENU_NAME)]
        private static void AssetRemovalMenuCommand()
        {
            AutoCleanup = true;
            StartRemovalUserFlow(true);
        }

        private static void HandleDelayCall()
        {
            bool startAutoDeprecation = !Application.isBatchMode &&
                                        AutoCleanup &&
                                        !Application.isPlaying;
            if (startAutoDeprecation)
            {
                StartRemovalUserFlow(false);
            }
        }

        /// <summary>
        /// Check if there are any assets in the project that require
        /// cleanup operations.
        /// </summary>
        /// <returns>True if package needs cleanup</returns>
        public static bool CheckPackageNeedsCleanup()
        {
            return GetAssetInfos().Count > 0;
        }

        /// <summary>
        /// Start the removal flow for removing deprecated assets.
        /// </summary>
        /// <param name="userTriggered">If true, the window will
        /// be non-modal, and a dialog will be shown if no assets found</param>
        public static void StartRemovalUserFlow(bool userTriggered)
        {
            var assetInfos = GetAssetInfos();

            if (assetInfos.Count == 0)
            {
                if (userTriggered)
                {
                    EditorUtility.DisplayDialog("Interaction SDK",
                        "No clean up needed in package.", "Close");
                }
                else
                {
                    return;
                }
            }
            else
            {
                int deletionPromptResult = EditorUtility.DisplayDialogComplex(
                    "Interaction SDK",
                    "This utility performs a cleanup operation which relocates " +
                    "Interaction SDK files and folders, and removes asset stubs provided " +
                    "for backwards compatibility during package upgrade." +
                    "\n\n" +
                    "Click 'Show Assets' to view a list of the assets to be modified. " +
                    "You will then be given the option to run the cleanup operation on them.",
                    "Show Assets (Recommended)", "No, Don't Ask Again", "No");

                switch (deletionPromptResult)
                {
                    case 0: // "Yes"
                        bool modalWindow = !userTriggered;
                        ShowAssetCleanupWindow(assetInfos, modalWindow);
                        break;
                    case 1: // "No, Don't Ask Again"
                        AutoCleanup = false;
                        ShowCancelDialog();
                        break;
                    default:
                    case 2: // "No"
                        AutoCleanup = true;
                        break;
                }
            }
        }

        private static IReadOnlyList<CleanupInfo> GetAssetInfos()
        {
            List<CleanupInfo> result = new List<CleanupInfo>();

            var deprecatedGUIDs = AssetDatabase.FindAssets($"l:{DEPRECATED_TAG}", null)
                .Select((guidStr) => new GUID(guidStr));
            var movedGUIDs = AssetDatabase.FindAssets($"l:{MOVED_TAG}", null)
                .Select((guidStr) => new GUID(guidStr));

            foreach (var GUID in deprecatedGUIDs)
            {
                result.Add(new CleanupInfo()
                {
                    Operation = CleanupOperation.Delete,
                    AssetGuid = GUID,
                });
            }

            foreach (var GUID in movedGUIDs)
            {
                if (GetDestFolderForMovedAsset(GUID, out GUID newPathGUID))
                {
                    result.Add(new CleanupInfo()
                    {
                        Operation = CleanupOperation.Move,
                        AssetGuid = GUID,
                        MoveToPathGuid = newPathGUID,
                    });
                }
                else
                {
                    result.Add(new CleanupInfo()
                    {
                        Operation = CleanupOperation.StripTags,
                        AssetGuid = GUID,
                    });
                }
            }

            result.RemoveAll((info) =>
            {
                // Ignore assets in read-only packages
                var pSource = PackageInfo.FindForAssetPath(
                    AssetDatabase.GUIDToAssetPath(info.AssetGuid))?.source;
                return pSource != null && // In Assets folder
                       pSource != PackageSource.Embedded &&
                       pSource != PackageSource.Local;
            });

            return result;
        }

        private static void ShowAssetCleanupWindow(
            IEnumerable<CleanupInfo> cleanupInfos, bool modal)
        {
            void DrawHeader(AssetListWindow window)
            {
                EditorGUILayout.HelpBox(
                    "Assets marked Delete will be permanently deleted",
                    MessageType.Warning);
            }

            void DrawFooter(AssetListWindow window)
            {
                GUILayoutOption buttonHeight = GUILayout.Height(36);
                EditorGUILayout.BeginHorizontal();

                if (GUILayout.Button("Clean Up (Recommended)", buttonHeight))
                {
                    var result = CleanUpAssets(cleanupInfos);
                    switch (result)
                    {
                        default:
                        case CleanupResult.None:
                        case CleanupResult.Cancel:
                            AutoCleanup = true;
                            break;
                        case CleanupResult.Success:
                        case CleanupResult.Incomplete:
                            AutoCleanup = false;
                            window.Close();
                            break;
                    }
                }
                if (GUILayout.Button("Cancel", buttonHeight))
                {
                    ShowCancelDialog();
                }
                EditorGUILayout.EndHorizontal();
            }

            List<AssetListWindow.AssetInfo> windowInfos =
                new List<AssetListWindow.AssetInfo>();

            foreach (var info in cleanupInfos)
            {
                switch (info.Operation)
                {
                    default:
                    case CleanupOperation.None:
                        break;
                    case CleanupOperation.Delete:
                        windowInfos.Add(new AssetListWindow.AssetInfo(
                            GUIDToAssetPath(info.AssetGuid),
                            $"<color=orange>Delete:</color> " +
                            $"{GUIDToAssetPath(info.AssetGuid)}"));
                        break;
                    case CleanupOperation.Move:
                        windowInfos.Add(new AssetListWindow.AssetInfo(
                            GUIDToAssetPath(info.AssetGuid),
                            $"<color=yellow>Move:</color> " +
                            $"{GUIDToAssetPath(info.AssetGuid)} -> " +
                            $"{GUIDToAssetPath(info.MoveToPathGuid)}"));
                        break;
                    case CleanupOperation.StripTags:
                        windowInfos.Add(new AssetListWindow.AssetInfo(
                            GUIDToAssetPath(info.AssetGuid),
                            $"<color=lime>Unlabel:</color> " +
                            $"{GUIDToAssetPath(info.AssetGuid)}"));
                        break;
                }
            }

            AssetListWindow assetListWindow = AssetListWindow.Show(
                "Interaction SDK - All Assets to be Modified",
                windowInfos, modal, DrawHeader, DrawFooter);
        }

        private static void ShowCancelDialog()
        {
            AssetListWindow.CloseAll();
            EditorUtility.DisplayDialog("Interaction SDK",
                $"Package cleanup was not run. " +
                $"You can run this utility at any time " +
                $"using the '{MENU_NAME}' menu.",
                "Close");
        }

        private static bool GetDestFolderForMovedAsset(GUID assetGUID, out GUID destFolderGUID)
        {
            destFolderGUID = new GUID();

            Object assetObject = AssetDatabase.LoadMainAssetAtPath(GUIDToAssetPath(assetGUID));
            List<string> labels = new List<string>(AssetDatabase.GetLabels(assetObject));

            int index = labels.FindIndex((l) => l.Contains(MOVED_TAG));
            if (index >= 0)
            {
                destFolderGUID = new GUID(labels[index].Remove(0, MOVED_TAG.Length));

                // Verify that paths exist, and new path is not the same as old path
                string curPath = Path.GetFullPath(GUIDToAssetPath(assetGUID));
                string newFolder = Path.GetFullPath(GUIDToAssetPath(destFolderGUID));
                string targetFilePath = Path.Combine(newFolder, Path.GetFileName(curPath));

                if (!curPath.Equals(targetFilePath) &&
                    (Directory.Exists(curPath) || File.Exists(curPath)) &&
                    Directory.Exists(newFolder))
                {
                    return true;
                }
            }

            return false;
        }

        private static CleanupResult CleanUpAssets(IEnumerable<CleanupInfo> cleanupInfos)
        {
            if (EditorUtility.DisplayDialog("Are you sure?",
                "Any assets marked for deletion will be permanently deleted." +
                "\n\n" +
                "It is strongly recommended that you back up your project before proceeding.",
                "Clean Up Package", "Cancel"))
            {
                var deletions = new List<GUID>();
                var moves = new Dictionary<GUID, GUID>();
                var stripTags = new List<GUID>();

                foreach (var info in cleanupInfos)
                {
                    switch (info.Operation)
                    {
                        default:
                        case CleanupOperation.None:
                            break;
                        case CleanupOperation.Delete:
                            deletions.Add(info.AssetGuid);
                            break;
                        case CleanupOperation.Move:
                            moves.Add(info.AssetGuid, info.MoveToPathGuid);
                            break;
                        case CleanupOperation.StripTags:
                            stripTags.Add(info.AssetGuid);
                            break;
                    }
                }

                bool result = true;
                result &= MoveAssets(moves);
                result &= DeleteAssets(deletions);
                result &= StripTags(stripTags);
                return result ? CleanupResult.Success : CleanupResult.Incomplete;
            }
            else
            {
                return CleanupResult.Cancel;
            }
        }

        private static bool MoveAssets(IDictionary<GUID, GUID> curToNewPathGUID)
        {
            Dictionary<string, string> moves = new Dictionary<string, string>();
            Dictionary<string, string> failures = new Dictionary<string, string>();

            foreach (var assetGUID in curToNewPathGUID.Keys)
            {
                if (!curToNewPathGUID.TryGetValue(assetGUID, out GUID newPathGUID))
                {
                    string failedPath = GUIDToAssetPath(assetGUID);
                    failures.Add(failedPath, $"No new path provided for asset {failedPath}");
                    continue;
                }

                string curPath = GUIDToAssetPath(assetGUID);
                string newPath = Path.Combine(GUIDToAssetPath(newPathGUID),
                    Path.GetFileName(curPath));

                if (Path.GetFullPath(curPath).Equals(Path.GetFullPath(newPath)))
                {
                    // Source and destination paths already match
                    continue;
                }

                string result = AssetDatabase.MoveAsset(curPath, newPath);

                if (!string.IsNullOrEmpty(result))
                {
                    failures.Add(curPath, result);
                }
                else
                {
                    // Strip labels after successful move
                    StripTag(assetGUID, MOVED_TAG);
                    moves.Add(curPath, newPath);
                }
            }

            string logMessage;
            if (BuildLogMessage("Assets moved:",
                moves.Keys.Select((key) => $"{key} -> {moves[key]}"),
                out logMessage))
            {
                Debug.Log(logMessage);
            }
            if (BuildLogMessage("Could not move assets:",
                failures.Keys.Select((key) => $"{key}:{failures[key]}"),
                out logMessage))
            {
                Debug.LogError(logMessage);
            }
            return failures.Count == 0;
        }

        private static bool DeleteAssets(IEnumerable<GUID> assetGUIDs)
        {
            var assetPaths = assetGUIDs
                .Select((guid) => GUIDToAssetPath(guid));

            HashSet<string> filesToDelete = new HashSet<string>();
            HashSet<string> foldersToDelete = new HashSet<string>();
            HashSet<string> skippedFolders = new HashSet<string>();
            HashSet<string> failedPaths = new HashSet<string>();

            foreach (var path in assetPaths)
            {
                if (File.Exists(path))
                {
                    filesToDelete.Add(path);
                }
                else if (Directory.Exists(path))
                {
                    foldersToDelete.Add(path);
                }
                else
                {
                    failedPaths.Add(path);
                }
            }

#if UNITY_2020_1_OR_NEWER
            List<string> failed = new List<string>();

            // Delete files
            AssetDatabase.DeleteAssets(filesToDelete.ToArray(), failed);
            failedPaths.UnionWith(failed);

            // Remove non-empty folders from delete list
            skippedFolders.UnionWith(foldersToDelete
                .Where((path) => AssetDatabase.FindAssets("", new[] { path })
                .Select((guid) => AssetDatabase.GUIDToAssetPath(guid))
                .Any((path) => !AssetDatabase.IsValidFolder(path))));
            foldersToDelete.ExceptWith(skippedFolders);

            // Delete folders, removing longest paths (subfolders) first
            List<string> sortedFolders = new List<string>(foldersToDelete);
            sortedFolders.Sort((a, b) => b.Length.CompareTo(a.Length));
            AssetDatabase.DeleteAssets(sortedFolders.ToArray(), failed);
            failedPaths.UnionWith(failed);
#else
            // Delete files
            foreach (var path in filesToDelete)
            {
                if (!AssetDatabase.DeleteAsset(path))
                {
                    failedPaths.Add(path);
                }
            }

            // Remove non-empty folders from delete list
            skippedFolders.UnionWith(foldersToDelete
                .Where((path) => Directory.EnumerateFiles(path).Any()));
            foldersToDelete.ExceptWith(skippedFolders);

            // Delete folders
            foreach (var path in foldersToDelete)
            {
                if (!AssetDatabase.DeleteAsset(path))
                {
                    failedPaths.Add(path);
                }
            }
#endif
            string logMessage;

            if (BuildLogMessage("Deprecated assets deleted:",
                filesToDelete.Union(foldersToDelete), out logMessage))
            {
                Debug.Log(logMessage);
            }
            if (BuildLogMessage("Skipped non-empty folders:",
                skippedFolders, out logMessage))
            {
                Debug.LogWarning(logMessage);
            }
            if (BuildLogMessage("Failed to delete assets:",
                failedPaths, out logMessage))
            {
                Debug.LogError(logMessage);
            }

            return failedPaths.Count == 0;
        }

        private static bool StripTags(IEnumerable<GUID> assetGUIDs)
        {
            foreach (var GUID in assetGUIDs)
            {
                StripTag(GUID, DEPRECATED_TAG);
                StripTag(GUID, MOVED_TAG);
            }
            return true;
        }

        private static void StripTag(in GUID assetGUID, string tag)
        {
            string assetPath = GUIDToAssetPath(assetGUID);
            Object assetObject = AssetDatabase.LoadMainAssetAtPath(assetPath);
            List<string> labels = new List<string>(AssetDatabase.GetLabels(assetObject));
            labels.RemoveAll((l) => l.Contains(tag));
            AssetDatabase.SetLabels(assetObject, labels.ToArray());
        }

        private static bool BuildLogMessage(
            string title,
            IEnumerable<string> messages,
            out string message)
        {
            int count = 0;
            StringBuilder sb = new StringBuilder();

            sb.Append(title);
            foreach (var msg in messages)
            {
                sb.Append(System.Environment.NewLine);
                sb.Append(msg);
                ++count;
            }
            message = sb.ToString();
            return count > 0;
        }

        private static string GUIDToAssetPath(GUID guid)
        {
#if UNITY_2020_3_OR_NEWER
            return AssetDatabase.GUIDToAssetPath(guid);
#else
            return AssetDatabase.GUIDToAssetPath(guid.ToString());
#endif
        }
    }
}
