// Asset Naming Manager — the scan engine.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using UnityEditor;
using UnityEngine;

namespace AssetNamingManager
{
    /// <summary>Knobs for a single <see cref="NamingScanner.Scan"/> call.</summary>
    public class ScanOptions
    {
        /// <summary>Folders to walk. Null falls back to the rule set's own scan roots.</summary>
        public string[] roots;

        /// <summary>
        /// When set, only these exact asset paths are examined and <see cref="roots"/> is ignored.
        /// Used by the import hook and by "Check selected assets".
        /// </summary>
        public string[] explicitPaths;

        /// <summary>Include folder-level checks (empty folders, folder naming).</summary>
        public bool includeFolders = true;

        /// <summary>Run the cross-project duplicate pass. Skipped for tiny incremental scans.</summary>
        public bool includeDuplicates = true;

        /// <summary>
        /// Called as the scan progresses. Return false to cancel.
        /// Signature is (progress 0-1, status text) =&gt; keepGoing.
        /// </summary>
        public Func<float, string, bool> onProgress;
    }

    /// <summary>
    /// Walks the project and produces a <see cref="ScanReport"/>. Read-only: this class never
    /// touches an asset. Everything that changes files lives in <see cref="NamingFixer"/>.
    /// </summary>
    public static class NamingScanner
    {
        const int ProgressInterval = 64;

        /// <summary>Runs a full scan against a rule set.</summary>
        public static ScanReport Scan(NamingRuleSet ruleSet, ScanOptions options = null)
        {
            var report = new ScanReport();
            if (ruleSet == null)
            {
                UnityEngine.Debug.LogError("[Asset Naming Manager] No rule set supplied. Nothing was scanned.");
                return report;
            }

            options = options ?? new ScanOptions();
            report.ruleSetName = ruleSet.name;

            var stopwatch = Stopwatch.StartNew();
            bool needsTypes = RuleSetNeedsTypeLookup(ruleSet);

            List<string> assetPaths;
            List<string> folderPaths;
            Collect(ruleSet, options, out assetPaths, out folderPaths, report);

            // Duplicate bookkeeping: key -> first path that claimed it.
            var duplicateKeys = new Dictionary<string, string>(StringComparer.Ordinal);
            var duplicateReported = new HashSet<string>(StringComparer.Ordinal);

            int totalUnits = assetPaths.Count + (options.includeFolders ? folderPaths.Count : 0);
            int unit = 0;

            // ------------------------------------------------------------ assets
            for (int i = 0; i < assetPaths.Count; i++)
            {
                string path = assetPaths[i];

                if (++unit % ProgressInterval == 0 && options.onProgress != null)
                {
                    if (!options.onProgress((float)unit / Mathf.Max(1, totalUnits), path))
                    {
                        report.cancelled = true;
                        break;
                    }
                }

                string extension = GetExtension(path);
                string fileName = GetFileNameWithoutExtension(path);
                string typeName = needsTypes ? TypeNameOf(path) : string.Empty;

                report.assetsScanned++;

                var rule = ruleSet.ResolveRule(path, extension, typeName);
                string ideal = NamingFixer.ComputeIdealName(ruleSet, rule, fileName);

                InspectAsset(ruleSet, rule, report, path, fileName, extension, typeName, ideal);

                if (options.includeDuplicates && ruleSet.checkDuplicateNames)
                {
                    TrackDuplicate(ruleSet, report, duplicateKeys, duplicateReported,
                                   path, fileName, extension, typeName, needsTypes);
                }
            }

            // ------------------------------------------------------------ folders
            if (options.includeFolders && !report.cancelled)
            {
                var populated = BuildPopulatedFolderSet(assetPaths);

                for (int i = 0; i < folderPaths.Count; i++)
                {
                    string path = folderPaths[i];

                    if (++unit % ProgressInterval == 0 && options.onProgress != null)
                    {
                        if (!options.onProgress((float)unit / Mathf.Max(1, totalUnits), path))
                        {
                            report.cancelled = true;
                            break;
                        }
                    }

                    report.foldersScanned++;
                    InspectFolder(ruleSet, report, path, populated);
                }
            }

            stopwatch.Stop();
            report.durationSeconds = stopwatch.Elapsed.TotalSeconds;
            report.InvalidateCounts();
            return report;
        }

        // ================================================================ collection

        static void Collect(NamingRuleSet ruleSet, ScanOptions options,
                            out List<string> assetPaths, out List<string> folderPaths, ScanReport report)
        {
            assetPaths = new List<string>();
            folderPaths = new List<string>();

            if (options.explicitPaths != null)
            {
                var picked = new HashSet<string>(StringComparer.Ordinal);
                var pickedFolders = new List<string>();

                foreach (var raw in options.explicitPaths)
                {
                    string path = GlobMatcher.NormalizePath(raw);
                    if (string.IsNullOrEmpty(path) || !picked.Add(path)) continue;
                    if (ruleSet.IsIgnored(path)) { report.assetsIgnored++; continue; }

                    if (AssetDatabase.IsValidFolder(path)) { folderPaths.Add(path); pickedFolders.Add(path); }
                    else assetPaths.Add(path);
                }

                // Selecting a folder means "everything under it". Without this the scan checked
                // only the folder's own name and reported nothing, which reads as a broken tool.
                if (pickedFolders.Count > 0)
                {
                    foreach (var guid in AssetDatabase.FindAssets(string.Empty, pickedFolders.ToArray()))
                    {
                        string path = AssetDatabase.GUIDToAssetPath(guid);
                        if (string.IsNullOrEmpty(path) || !picked.Add(path)) continue;
                        if (ruleSet.IsIgnored(path)) { report.assetsIgnored++; continue; }

                        if (AssetDatabase.IsValidFolder(path)) folderPaths.Add(path);
                        else assetPaths.Add(path);
                    }
                }

                assetPaths.Sort(StringComparer.Ordinal);
                folderPaths.Sort(StringComparer.Ordinal);
                return;
            }

            string[] roots = options.roots != null && options.roots.Length > 0
                ? options.roots
                : ruleSet.ResolvedScanRoots();

            var valid = new List<string>(roots.Length);
            foreach (var root in roots)
            {
                string normalized = GlobMatcher.NormalizePath(root);
                if (string.IsNullOrEmpty(normalized)) continue;
                if (normalized == "Assets" || AssetDatabase.IsValidFolder(normalized)) valid.Add(normalized);
                else UnityEngine.Debug.LogWarning("[Asset Naming Manager] Scan root does not exist and was skipped: " + normalized);
            }
            if (valid.Count == 0) valid.Add("Assets");

            var seen = new HashSet<string>(StringComparer.Ordinal);
            string[] guids = AssetDatabase.FindAssets(string.Empty, valid.ToArray());

            foreach (var guid in guids)
            {
                string path = AssetDatabase.GUIDToAssetPath(guid);
                if (string.IsNullOrEmpty(path) || !seen.Add(path)) continue;

                if (ruleSet.IsIgnored(path)) { report.assetsIgnored++; continue; }

                // FindAssets returns folders as well; they get their own, cheaper pass.
                if (AssetDatabase.IsValidFolder(path)) folderPaths.Add(path);
                else assetPaths.Add(path);
            }

            assetPaths.Sort(StringComparer.Ordinal);
            folderPaths.Sort(StringComparer.Ordinal);
        }

        static HashSet<string> BuildPopulatedFolderSet(List<string> assetPaths)
        {
            // A folder counts as populated when any asset lives in it or below it.
            var populated = new HashSet<string>(StringComparer.Ordinal);
            foreach (var path in assetPaths)
            {
                int slash = path.LastIndexOf('/');
                while (slash > 0)
                {
                    string folder = path.Substring(0, slash);
                    if (!populated.Add(folder)) break;      // ancestors already recorded
                    slash = folder.LastIndexOf('/');
                }
            }
            return populated;
        }

        // ================================================================ asset checks

        static void InspectAsset(NamingRuleSet ruleSet, NamingRule rule, ScanReport report,
                                 string path, string fileName, string extension, string typeName, string ideal)
        {
            // ---------------------------------------------------- project-wide checks
            string illegal = NameUtility.FindIllegalCharacter(fileName, ruleSet.forbiddenCharacters, ruleSet.asciiOnly);
            if (illegal != null)
            {
                Add(report, path, fileName, extension, typeName, ViolationKind.IllegalCharacter,
                    ruleSet.illegalCharacterSeverity, rule,
                    "Name contains " + illegal,
                    "Banned characters break command-line tools, asset bundle names and some " +
                    "version control clients. Remove it or replace it with an underscore.",
                    ideal, null);
            }

            if (ruleSet.checkRedundantTokens)
            {
                string junk = NameUtility.FindRedundantToken(fileName, ruleSet.redundantTokens);
                if (junk != null)
                {
                    Add(report, path, fileName, extension, typeName, ViolationKind.RedundantToken,
                        ruleSet.redundantTokenSeverity, rule,
                        "Name contains the junk word '" + junk + "'",
                        "Words like copy, final, new or temp describe a moment in time, not the asset. " +
                        "Six months from now nobody knows which 'final' is final.",
                        ideal, null);
                }
            }

            if (ruleSet.checkNumberPadding)
            {
                string under = NameUtility.FindUnderPaddedNumber(fileName, ruleSet.numberPaddingWidth);
                if (under != null)
                {
                    Add(report, path, fileName, extension, typeName, ViolationKind.NumberPadding,
                        ruleSet.numberPaddingSeverity, rule,
                        "Trailing number '" + under + "' is not padded to " + ruleSet.numberPaddingWidth + " digits",
                        "Unpadded numbers sort wrong everywhere: Run_10 lands before Run_2 in the Project " +
                        "window, in Explorer and in animation frame lists.",
                        ideal, null);
                }
            }

            if (ruleSet.checkLooseRootAssets && IsLooseInRoot(path))
            {
                // If a rule already knows where this asset type belongs, offer the move. Without a
                // preferred folder the Cop has no business guessing which category you meant, so the
                // finding stays report-only rather than inventing a destination.
                string home = rule != null && rule.allowAutoFix ? NullIfEmpty(rule.preferredFolder) : null;

                Add(report, path, fileName, extension, typeName, ViolationKind.LooseInRoot,
                    ruleSet.looseRootSeverity, rule,
                    home != null
                        ? "Sitting loose in the Assets root — belongs in " + home
                        : "Asset is sitting loose in the Assets root",
                    home != null
                        ? "The Assets root is where orphan files go to be forgotten. Rule '" +
                          rule.displayName + "' keeps this type in " + home + ", so the Cop can move it for you."
                        : "The Assets root is where orphan files go to be forgotten. Give this rule a " +
                          "preferred folder and the Cop can move it for you instead of just reporting it.",
                    null, home);
            }

            if (ruleSet.checkPathLength && path.Length > ruleSet.maxPathLength)
            {
                Add(report, path, fileName, extension, typeName, ViolationKind.PathTooLong,
                    ruleSet.pathLengthSeverity, rule,
                    "Path is " + path.Length + " characters (limit " + ruleSet.maxPathLength + ")",
                    "Windows tooling still trips over long paths. A build machine with a deep " +
                    "workspace folder will hit the 260 character wall before you do.",
                    null, null);
            }

            // ---------------------------------------------------- rule checks
            if (rule == null) return;

            bool canFix = rule.allowAutoFix;
            string fixName = canFix ? ideal : null;

            // Prefix
            if (rule.Prefixes.Length > 0 && rule.MatchedPrefix(fileName) == null)
            {
                Add(report, path, fileName, extension, typeName, ViolationKind.MissingPrefix,
                    rule.severity, rule,
                    "Missing prefix '" + rule.Prefixes[0] + "'",
                    "Rule '" + rule.displayName + "' expects names to start with " +
                    Join(rule.Prefixes, " or ") + ". A consistent prefix is what makes the Project " +
                    "window search box actually useful.",
                    fixName, null);
            }

            // Suffix
            if (rule.Suffixes.Length > 0 && rule.MatchedSuffix(fileName) == null)
            {
                Add(report, path, fileName, extension, typeName, ViolationKind.MissingSuffix,
                    rule.severity, rule,
                    "Missing suffix '" + rule.Suffixes[0] + "'",
                    "Rule '" + rule.displayName + "' expects names to end with " +
                    Join(rule.Suffixes, " or ") + ".",
                    fixName, null);
            }

            // Convention, checked on the body only
            if (rule.convention != NamingConvention.Any)
            {
                string body = StripAffixes(fileName, rule);
                if (body.Length > 0 && !NameUtility.Matches(body, rule.convention))
                {
                    Add(report, path, fileName, extension, typeName, ViolationKind.ConventionMismatch,
                        rule.severity, rule,
                        "'" + body + "' is not " + rule.convention + " (" + Rewritten(fileName, ideal, rule.convention) + ")",
                        "Rule '" + rule.displayName + "' uses " + rule.convention + ". The check runs on the " +
                        "name body, so the prefix and suffix are not counted against you.",
                        fixName, null);
                }
            }

            // Length
            if (rule.minLength > 0 && fileName.Length < rule.minLength)
            {
                Add(report, path, fileName, extension, typeName, ViolationKind.TooShort,
                    rule.severity, rule,
                    "Name is " + fileName.Length + " characters, minimum is " + rule.minLength,
                    "Very short names are almost always placeholders that escaped into the project.",
                    null, null);
            }
            else if (rule.maxLength > 0 && fileName.Length > rule.maxLength)
            {
                Add(report, path, fileName, extension, typeName, ViolationKind.TooLong,
                    rule.severity, rule,
                    "Name is " + fileName.Length + " characters, maximum is " + rule.maxLength,
                    "Long names get truncated in the Project window and in most inspectors, " +
                    "which defeats the point of naming them carefully. The proposed name is cut to " +
                    "fit — check it before applying, because trimming a name always drops meaning.",
                    fixName, null);
            }

            // Custom regex
            var regex = rule.CompiledRegex;
            if (regex != null)
            {
                bool matched = regex.IsMatch(fileName);
                if (matched != rule.regexMustMatch)
                {
                    Add(report, path, fileName, extension, typeName, ViolationKind.RegexMismatch,
                        rule.severity, rule,
                        rule.regexMustMatch
                            ? "Name does not match " + rule.customRegex
                            : "Name matches the banned pattern " + rule.customRegex,
                        "Rule '" + rule.displayName + "' applies a custom pattern to the whole file name.",
                        null, null);
                }
            }

            // Folders
            if (rule.forbiddenFolders != null && rule.forbiddenFolders.Length > 0 &&
                GlobMatcher.IsMatchAny(path, rule.forbiddenFolders))
            {
                Add(report, path, fileName, extension, typeName, ViolationKind.ForbiddenFolder,
                    rule.severity, rule,
                    "This asset type is not allowed in this folder",
                    "Rule '" + rule.displayName + "' forbids " + Join(rule.forbiddenFolders, ", ") + ".",
                    null, canFix ? NullIfEmpty(rule.preferredFolder) : null);
            }
            else if (rule.allowedFolders != null && rule.allowedFolders.Length > 0 &&
                     !GlobMatcher.IsMatchAny(path, rule.allowedFolders))
            {
                Add(report, path, fileName, extension, typeName, ViolationKind.WrongFolder,
                    rule.severity, rule,
                    "Expected in " + Join(rule.allowedFolders, " or "),
                    "Rule '" + rule.displayName + "' keeps this asset type in a known place so that " +
                    "new team members do not have to ask where things live.",
                    null, canFix ? NullIfEmpty(rule.preferredFolder) : null);
            }
        }

        // ================================================================ folder checks

        static void InspectFolder(NamingRuleSet ruleSet, ScanReport report, string path, HashSet<string> populated)
        {
            string folderName = GetFileName(path);

            if (ruleSet.checkEmptyFolders && !populated.Contains(path))
            {
                Add(report, path, folderName, string.Empty, "Folder", ViolationKind.EmptyFolder,
                    ruleSet.emptyFolderSeverity, null,
                    "Folder is empty",
                    "Empty folders are usually leftovers from a refactor. Unity will not commit them " +
                    "to most version control systems either, so they quietly differ per machine.",
                    null, null);
            }

            if (ruleSet.checkFolderNaming && !NameUtility.Matches(folderName, ruleSet.folderConvention))
            {
                Add(report, path, folderName, string.Empty, "Folder", ViolationKind.FolderNaming,
                    ruleSet.folderNamingSeverity, null,
                    "Folder name is not " + ruleSet.folderConvention +
                    " (" + Rewritten(folderName, NameUtility.Convert(folderName, ruleSet.folderConvention),
                                     ruleSet.folderConvention) + ")",
                    "Folder names show up in every asset path, so they are worth the same care as file names.",
                    NameUtility.Convert(folderName, ruleSet.folderConvention), null);
            }

            string illegal = NameUtility.FindIllegalCharacter(folderName, ruleSet.forbiddenCharacters, ruleSet.asciiOnly);
            if (illegal != null)
            {
                bool cleanedAnything;
                string cleanFolder = NameUtility.StripIllegalCharacters(
                    folderName, ruleSet.forbiddenCharacters, ruleSet.asciiOnly, '_', out cleanedAnything);

                Add(report, path, folderName, string.Empty, "Folder", ViolationKind.IllegalCharacter,
                    ruleSet.illegalCharacterSeverity, null,
                    "Folder name contains " + illegal,
                    "A space or symbol in a folder name ends up in every asset path underneath it, " +
                    "so one bad folder quietly taints hundreds of paths.",
                    cleanedAnything ? cleanFolder : null, null);
            }
        }

        // ================================================================ duplicates

        static void TrackDuplicate(NamingRuleSet ruleSet, ScanReport report,
                                   Dictionary<string, string> keys, HashSet<string> reported,
                                   string path, string fileName, string extension, string typeName, bool haveTypes)
        {
            string key;
            switch (ruleSet.duplicateScope)
            {
                case DuplicateScope.NameAndExtension:
                    key = fileName.ToLowerInvariant() + "|" + extension;
                    break;
                case DuplicateScope.NameAndType:
                    // Without a type lookup we cannot honour this scope, so fall back to extension.
                    key = fileName.ToLowerInvariant() + "|" + (haveTypes ? typeName : extension);
                    break;
                default:
                    key = fileName.ToLowerInvariant();
                    break;
            }

            string first;
            if (!keys.TryGetValue(key, out first))
            {
                keys[key] = path;
                return;
            }

            // Report the original once, then every later clash.
            if (reported.Add(first))
            {
                Add(report, first, GetFileNameWithoutExtension(first), GetExtension(first), typeName,
                    ViolationKind.DuplicateName, ruleSet.duplicateSeverity, null,
                    "Shares its name with " + path,
                    "Two assets with the same name are a reference bug waiting to happen: someone " +
                    "drags the wrong one into a prefab and nobody notices for a month. They can also " +
                    "both end up in the build.",
                    null, null);
            }

            Add(report, path, fileName, extension, typeName,
                ViolationKind.DuplicateName, ruleSet.duplicateSeverity, null,
                "Shares its name with " + first,
                "Two assets with the same name are a reference bug waiting to happen. Rename one of " +
                "them to say what makes it different.",
                null, null);
            reported.Add(path);
        }

        // ================================================================ helpers

        static void Add(ScanReport report, string path, string name, string extension, string typeName,
                        ViolationKind kind, NamingSeverity severity, NamingRule rule,
                        string message, string detail, string suggestedName, string suggestedFolder)
        {
            if (!string.IsNullOrEmpty(suggestedName) && suggestedName == name) suggestedName = null;

            report.violations.Add(new AssetNamingViolation
            {
                assetPath = path,
                guid = AssetDatabase.AssetPathToGUID(path),
                assetName = name,
                extension = extension,
                typeName = string.IsNullOrEmpty(typeName) ? GuessTypeLabel(extension) : typeName,
                kind = kind,
                severity = severity,
                ruleName = rule != null ? rule.displayName : string.Empty,
                message = message,
                detail = rule != null && !string.IsNullOrWhiteSpace(rule.note)
                    ? detail + "\n\nTeam note: " + rule.note
                    : detail,
                suggestedName = suggestedName,
                suggestedFolder = suggestedFolder
            });
        }

        static bool RuleSetNeedsTypeLookup(NamingRuleSet ruleSet)
        {
            if (ruleSet.duplicateScope == DuplicateScope.NameAndType && ruleSet.checkDuplicateNames) return true;
            foreach (var rule in ruleSet.rules)
            {
                if (rule != null && rule.enabled && rule.TypeNames.Length > 0) return true;
            }
            return false;
        }

        static string TypeNameOf(string path)
        {
            try
            {
                var type = AssetDatabase.GetMainAssetTypeAtPath(path);
                return type != null ? type.Name : string.Empty;
            }
            catch (Exception)
            {
                return string.Empty;
            }
        }

        /// <summary>A readable type label derived from the extension, for reports and grouping.</summary>
        public static string GuessTypeLabel(string extension)
        {
            switch (extension)
            {
                case ".png": case ".jpg": case ".jpeg": case ".tga": case ".psd":
                case ".tif": case ".tiff": case ".exr": case ".bmp": case ".gif":
                case ".webp": case ".hdr":                              return "Texture";
                case ".mat":                                            return "Material";
                case ".prefab":                                         return "Prefab";
                case ".unity":                                          return "Scene";
                case ".cs":                                             return "Script";
                case ".shader": case ".shadergraph": case ".cginc":
                case ".hlsl": case ".shadersubgraph":                   return "Shader";
                case ".wav": case ".mp3": case ".ogg": case ".aiff":
                case ".aif": case ".flac":                              return "Audio";
                case ".fbx": case ".obj": case ".blend": case ".dae":
                case ".3ds": case ".gltf": case ".glb":                 return "Model";
                case ".anim":                                           return "Animation";
                case ".controller": case ".overridecontroller":         return "Animator";
                case ".asset":                                          return "ScriptableObject";
                case ".ttf": case ".otf":                               return "Font";
                case ".mp4": case ".mov": case ".webm":                 return "Video";
                case ".physicmaterial": case ".physicsmaterial2d":      return "Physics Material";
                case ".spriteatlas": case ".spriteatlasv2":             return "Sprite Atlas";
                case ".playable": case ".signal":                       return "Timeline";
                case ".json": case ".xml": case ".txt": case ".csv":    return "Text";
                case ".renderTexture":                                  return "Render Texture";
                case "":                                                return "Folder";
                default:                                                return "Other";
            }
        }

        /// <summary>
        /// What this particular name becomes, for the finding message.
        /// </summary>
        /// <remarks>
        /// It used to print <see cref="NameUtility.Example"/>, so every PascalCase finding in the
        /// project read "expected PlayerRunCycle" — an instruction to rename every asset to
        /// PlayerRunCycle rather than an example of the convention. It takes the same ideal name
        /// the fix will apply, so the message can never name one thing while the rename beside it
        /// does another.
        /// </remarks>
        static string Rewritten(string current, string ideal, NamingConvention convention)
        {
            // A name that failed the convention check should always have an ideal that differs,
            // but if it ever does not, fall back to wording that is honest about being an example.
            return string.IsNullOrEmpty(ideal) || string.Equals(ideal, current, StringComparison.Ordinal)
                ? "expected something like " + NameUtility.Example(convention)
                : "would be '" + ideal + "'";
        }

        static string StripAffixes(string name, NamingRule rule)
        {
            string body = name;

            string prefix = rule.MatchedPrefix(body);
            if (prefix != null) body = body.Substring(prefix.Length);

            string suffix = rule.MatchedSuffix(body);
            if (suffix != null && body.Length > suffix.Length) body = body.Substring(0, body.Length - suffix.Length);

            return body.Trim('_', '-', ' ');
        }

        static bool IsLooseInRoot(string path)
        {
            if (!path.StartsWith("Assets/", StringComparison.Ordinal)) return false;
            return path.IndexOf('/', "Assets/".Length) < 0;
        }

        static string NullIfEmpty(string s)
        {
            return string.IsNullOrWhiteSpace(s) ? null : s;
        }

        static string Join(string[] values, string separator)
        {
            return values == null || values.Length == 0 ? "(none)" : string.Join(separator, values);
        }

        /// <summary>Lower-case extension including the dot. Safe on any Unity path.</summary>
        public static string GetExtension(string path)
        {
            int dot = path.LastIndexOf('.');
            int slash = path.LastIndexOf('/');
            if (dot <= slash || dot < 0) return string.Empty;
            return path.Substring(dot).ToLowerInvariant();
        }

        /// <summary>File name with its extension stripped.</summary>
        public static string GetFileNameWithoutExtension(string path)
        {
            string file = GetFileName(path);
            int dot = file.LastIndexOf('.');
            return dot <= 0 ? file : file.Substring(0, dot);
        }

        /// <summary>Last path segment, extension included.</summary>
        public static string GetFileName(string path)
        {
            int slash = path.LastIndexOf('/');
            return slash < 0 ? path : path.Substring(slash + 1);
        }
    }
}
