// Asset Naming Manager — name tokenizing, validation and rewriting.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;

namespace AssetNamingManager
{
    /// <summary>
    /// Pure, Unity-free helpers that split a file name into words, tell you whether it
    /// follows a <see cref="NamingConvention"/>, and rewrite it so that it does.
    /// Everything here is deterministic and unit tested — the rest of the tool leans on it.
    /// </summary>
    public static class NameUtility
    {
        // A digit has no case, so a name that starts with one cannot violate a casing rule.
        // Real assets are called 3DGrid, 2DSprite and 8BitFont; rejecting them would report those
        // files forever, because no rewrite of "3DGrid" can ever start with an uppercase letter.
        static readonly Regex PascalRx      = new Regex(@"^[A-Z0-9][a-zA-Z0-9]*$", RegexOptions.Compiled);
        static readonly Regex CamelRx       = new Regex(@"^[a-z0-9][a-zA-Z0-9]*$", RegexOptions.Compiled);
        static readonly Regex SnakeRx       = new Regex(@"^[a-z0-9]+(_[a-z0-9]+)*$", RegexOptions.Compiled);
        static readonly Regex ScreamingRx   = new Regex(@"^[A-Z0-9]+(_[A-Z0-9]+)*$", RegexOptions.Compiled);
        static readonly Regex KebabRx       = new Regex(@"^[a-z0-9]+(-[a-z0-9]+)*$", RegexOptions.Compiled);
        // A segment may start with a digit so that Player_Attack_02 is accepted. Without that, a
        // numbered clip could be reported forever: the check would reject _02 and the fixer would
        // hand back the same name it just rejected.
        static readonly Regex PascalSnakeRx = new Regex(@"^[A-Z0-9][a-zA-Z0-9]*(_[A-Z0-9][a-zA-Z0-9]*)*$", RegexOptions.Compiled);
        static readonly Regex TrailingNumRx = new Regex(@"(?<=[^0-9])([0-9]+)$", RegexOptions.Compiled);
        static readonly Regex CopySuffixRx  = new Regex(@"[ _-]*(\(\d+\))?[ _-]*(copy|kopie|copia)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);

        /// <summary>Words the Cop treats as leftover junk when redundant-token checking is on.</summary>
        public static readonly string[] DefaultRedundantTokens =
        {
            "copy", "final", "new", "old", "temp", "tmp", "test",
            "backup", "bak", "untitled", "duplicate", "dup", "wip"
        };

        // ---------------------------------------------------------------- tokens

        /// <summary>
        /// Splits a name into words. Handles separators (<c>_ - . space</c>), camelCase humps,
        /// acronym runs (<c>HTTPServer</c> becomes <c>HTTP</c> + <c>Server</c>) and letter-to-digit
        /// boundaries (<c>Run02</c> becomes <c>Run</c> + <c>02</c>), while keeping <c>2D</c> and
        /// <c>3D</c> style tokens fused.
        /// </summary>
        public static List<string> Tokenize(string name)
        {
            var tokens = new List<string>();
            if (string.IsNullOrEmpty(name)) return tokens;

            var sb = new StringBuilder();
            for (int i = 0; i < name.Length; i++)
            {
                char c = name[i];

                if (!char.IsLetterOrDigit(c))
                {
                    Flush(tokens, sb);
                    continue;
                }

                if (sb.Length > 0)
                {
                    char prev = name[i - 1];
                    bool startsNewWord = false;

                    if (char.IsUpper(c) && (char.IsLower(prev) || char.IsDigit(prev)))
                    {
                        // Do not split the D of "2D" away from its digits.
                        startsNewWord = !(char.IsDigit(prev) && IsDimensionToken(name, i));
                    }
                    else if (char.IsUpper(c) && char.IsUpper(prev) && i + 1 < name.Length && char.IsLower(name[i + 1]))
                    {
                        startsNewWord = true;   // HTTPServer -> HTTP | Server
                    }
                    else if (char.IsDigit(c) && char.IsLetter(prev))
                    {
                        startsNewWord = true;   // Run02 -> Run | 02
                    }

                    if (startsNewWord) Flush(tokens, sb);
                }

                sb.Append(c);
            }

            Flush(tokens, sb);
            return tokens;
        }

        // "2D" and "3D" must survive tokenizing whether they end the name (Icon2D) or lead it
        // (3DGrid). The test is what follows the D: end of name, or anything that is not a
        // lowercase letter, means the dimension token is complete. A lowercase letter means we
        // are inside a longer word such as "2Dimension", which should split normally.
        static bool IsDimensionToken(string name, int i)
        {
            char c = name[i];
            if (c != 'D' && c != 'd' && c != 'X' && c != 'x') return false;
            return i + 1 >= name.Length || !char.IsLower(name[i + 1]);
        }

        static void Flush(List<string> tokens, StringBuilder sb)
        {
            if (sb.Length == 0) return;
            tokens.Add(sb.ToString());
            sb.Length = 0;
        }

        // ---------------------------------------------------------------- validate

        /// <summary>True when <paramref name="body"/> already matches <paramref name="convention"/>.</summary>
        public static bool Matches(string body, NamingConvention convention)
        {
            if (convention == NamingConvention.Any) return true;
            if (string.IsNullOrEmpty(body)) return false;

            switch (convention)
            {
                case NamingConvention.PascalCase:         return PascalRx.IsMatch(body);
                case NamingConvention.CamelCase:          return CamelRx.IsMatch(body);
                case NamingConvention.SnakeCase:          return SnakeRx.IsMatch(body);
                case NamingConvention.ScreamingSnakeCase: return ScreamingRx.IsMatch(body);
                case NamingConvention.KebabCase:          return KebabRx.IsMatch(body);
                case NamingConvention.PascalSnakeCase:    return PascalSnakeRx.IsMatch(body);
                default:                                  return true;
            }
        }

        /// <summary>A short human example of a convention, used in tooltips and messages.</summary>
        public static string Example(NamingConvention convention)
        {
            switch (convention)
            {
                case NamingConvention.PascalCase:         return "PlayerRunCycle";
                case NamingConvention.CamelCase:          return "playerRunCycle";
                case NamingConvention.SnakeCase:          return "player_run_cycle";
                case NamingConvention.ScreamingSnakeCase: return "PLAYER_RUN_CYCLE";
                case NamingConvention.KebabCase:          return "player-run-cycle";
                case NamingConvention.PascalSnakeCase:    return "Player_Run_Cycle";
                default:                                  return "anything goes";
            }
        }

        // ---------------------------------------------------------------- rewrite

        /// <summary>Rewrites <paramref name="body"/> so that <see cref="Matches"/> returns true.</summary>
        public static string Convert(string body, NamingConvention convention)
        {
            if (convention == NamingConvention.Any) return body;

            var tokens = Tokenize(body);
            if (tokens.Count == 0) return body;

            var sb = new StringBuilder();
            for (int i = 0; i < tokens.Count; i++)
            {
                string t = tokens[i];
                switch (convention)
                {
                    case NamingConvention.PascalCase:
                        sb.Append(Capitalize(t));
                        break;
                    case NamingConvention.CamelCase:
                        sb.Append(i == 0 ? Decapitalize(t) : Capitalize(t));
                        break;
                    case NamingConvention.SnakeCase:
                        if (i > 0) sb.Append('_');
                        sb.Append(t.ToLowerInvariant());
                        break;
                    case NamingConvention.ScreamingSnakeCase:
                        if (i > 0) sb.Append('_');
                        sb.Append(t.ToUpperInvariant());
                        break;
                    case NamingConvention.KebabCase:
                        if (i > 0) sb.Append('-');
                        sb.Append(t.ToLowerInvariant());
                        break;
                    case NamingConvention.PascalSnakeCase:
                        if (i > 0) sb.Append('_');
                        sb.Append(Capitalize(t));
                        break;
                }
            }
            return sb.ToString();
        }

        /// <summary>"player" becomes "Player". An all-caps acronym such as "UI" is left alone.</summary>
        public static string Capitalize(string token)
        {
            if (string.IsNullOrEmpty(token)) return token;
            if (token.Length > 1 && IsAllUpperLetters(token)) return token;   // UI, HUD, FX, LOD

            // Capitalise the first LETTER, which is not always the first character: a token such as
            // "3d" should read "3D", not stay lowercase because a digit came first.
            for (int i = 0; i < token.Length; i++)
            {
                if (!char.IsLetter(token[i])) continue;
                return token.Substring(0, i) + char.ToUpperInvariant(token[i]) + token.Substring(i + 1);
            }

            return token;   // digits only, e.g. "02"
        }

        /// <summary>"Player" becomes "player". An acronym is lowered whole so camelCase stays readable.</summary>
        public static string Decapitalize(string token)
        {
            if (string.IsNullOrEmpty(token)) return token;
            if (token.Length > 1 && IsAllUpperLetters(token)) return token.ToLowerInvariant();
            return char.ToLowerInvariant(token[0]) + token.Substring(1);
        }

        static bool IsAllUpperLetters(string s)
        {
            bool sawLetter = false;
            foreach (char c in s)
            {
                if (char.IsLower(c)) return false;
                if (char.IsLetter(c)) sawLetter = true;
            }
            return sawLetter;
        }

        // ---------------------------------------------------------------- cleaning

        /// <summary>
        /// Strips characters the project bans and collapses the separator runs they leave behind.
        /// <paramref name="removed"/> reports whether anything actually changed.
        /// </summary>
        public static string StripIllegalCharacters(string name, string forbiddenCharacters,
                                                    bool asciiOnly, char replacement, out bool removed)
        {
            var sb = new StringBuilder(name.Length);
            removed = false;

            foreach (char c in name)
            {
                bool bad = !string.IsNullOrEmpty(forbiddenCharacters) && forbiddenCharacters.IndexOf(c) >= 0;
                if (!bad && asciiOnly && c > 127) bad = true;

                if (bad)
                {
                    removed = true;
                    if (replacement != '\0') sb.Append(replacement);
                }
                else
                {
                    sb.Append(c);
                }
            }

            return CollapseSeparators(sb.ToString());
        }

        /// <summary>Finds the first banned character in a name, or null when the name is clean.</summary>
        public static string FindIllegalCharacter(string name, string forbiddenCharacters, bool asciiOnly)
        {
            foreach (char c in name)
            {
                if (!string.IsNullOrEmpty(forbiddenCharacters) && forbiddenCharacters.IndexOf(c) >= 0)
                    return c == ' ' ? "space" : c.ToString();
                if (asciiOnly && c > 127)
                    return "non-ASCII '" + c + "'";
            }
            return null;
        }

        /// <summary>Turns <c>Player__Run--01_</c> into <c>Player_Run-01</c>.</summary>
        public static string CollapseSeparators(string name)
        {
            var sb = new StringBuilder(name.Length);
            char last = '\0';
            foreach (char c in name)
            {
                bool sep = c == '_' || c == '-' || c == ' ';
                if (sep && (last == '_' || last == '-' || last == ' ' || sb.Length == 0)) continue;
                sb.Append(c);
                last = c;
            }
            while (sb.Length > 0 && (sb[sb.Length - 1] == '_' || sb[sb.Length - 1] == '-' || sb[sb.Length - 1] == ' '))
                sb.Length--;
            return sb.ToString();
        }

        /// <summary>Finds junk words such as "copy" or "final" inside a name. Returns null when clean.</summary>
        public static string FindRedundantToken(string name, IList<string> redundantTokens)
        {
            if (redundantTokens == null || redundantTokens.Count == 0) return null;

            var tokens = Tokenize(name);

            // A single-word name that *is* the junk word (a scene called "Test") is usually
            // deliberate, so only flag junk that hitch-hikes on an otherwise real name.
            if (tokens.Count < 2) return null;

            foreach (var token in tokens)
            {
                string lower = token.ToLowerInvariant();
                foreach (var junk in redundantTokens)
                {
                    if (string.IsNullOrWhiteSpace(junk)) continue;
                    if (lower == junk.Trim().ToLowerInvariant()) return token;
                }
            }
            return null;
        }

        /// <summary>Removes every junk word from a name, keeping the remaining tokens in order.</summary>
        public static string RemoveRedundantTokens(string name, IList<string> redundantTokens)
        {
            if (redundantTokens == null || redundantTokens.Count == 0) return name;

            string working = CopySuffixRx.Replace(name, string.Empty);
            var tokens = Tokenize(working);
            if (tokens.Count < 2) return CollapseSeparators(working);

            var kept = new List<string>(tokens.Count);
            foreach (var token in tokens)
            {
                string lower = token.ToLowerInvariant();
                bool junk = false;
                foreach (var j in redundantTokens)
                {
                    if (string.IsNullOrWhiteSpace(j)) continue;
                    if (lower == j.Trim().ToLowerInvariant()) { junk = true; break; }
                }
                if (!junk) kept.Add(token);
            }

            if (kept.Count == 0) return CollapseSeparators(working);
            return string.Join("_", kept.ToArray());
        }

        // ---------------------------------------------------------------- numbers

        /// <summary>
        /// Returns the trailing number of a name when it is padded to fewer than
        /// <paramref name="width"/> digits, otherwise null. <c>Run_1</c> at width 2 returns "1".
        /// </summary>
        public static string FindUnderPaddedNumber(string name, int width)
        {
            if (width <= 1 || string.IsNullOrEmpty(name)) return null;
            var m = TrailingNumRx.Match(name);
            if (!m.Success) return null;
            string digits = m.Groups[1].Value;
            return digits.Length < width ? digits : null;
        }

        /// <summary>Zero-pads the trailing number of a name to <paramref name="width"/> digits.</summary>
        public static string PadTrailingNumber(string name, int width)
        {
            if (width <= 1) return name;
            var m = TrailingNumRx.Match(name);
            if (!m.Success) return name;

            string digits = m.Groups[1].Value;
            if (digits.Length >= width) return name;

            int value = int.Parse(digits, CultureInfo.InvariantCulture);
            return name.Substring(0, m.Groups[1].Index) +
                   value.ToString(new string('0', width), CultureInfo.InvariantCulture);
        }

        // ---------------------------------------------------------------- misc

        /// <summary>Number of leading characters two names share — used to highlight the diff in the UI.</summary>
        public static int CommonPrefixLength(string a, string b)
        {
            if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0;
            int n = Math.Min(a.Length, b.Length), i = 0;
            while (i < n && a[i] == b[i]) i++;
            return i;
        }

        /// <summary>Splits a comma / pipe / semicolon separated option string into trimmed entries.</summary>
        public static string[] SplitList(string value)
        {
            if (string.IsNullOrWhiteSpace(value)) return new string[0];

            var parts = value.Split(new[] { ',', '|', ';' }, StringSplitOptions.RemoveEmptyEntries);
            var list = new List<string>(parts.Length);
            foreach (var p in parts)
            {
                string t = p.Trim();
                if (t.Length > 0) list.Add(t);
            }
            return list.ToArray();
        }
    }
}
