using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;

namespace Getly.Publisher
{
    /// <summary>A single problem found in the package being prepared.</summary>
    public sealed class GetlyFinding
    {
        public string Id;
        /// <summary>Asset path, relative to the project. Empty for package-wide findings.</summary>
        public string Path;
        /// <summary>Extra context, e.g. "4096x4096". Optional.</summary>
        public string Detail;

        public GetlyFinding(string id, string path = null, string detail = null)
        {
            Id = id;
            Path = path;
            Detail = detail;
        }

        public GetlyCheck Check => GetlyChecks.Get(Id);
        public bool IsBlocking => Check != null && Check.Severity == GetlySeverity.Error;
    }

    /// <summary>What the validator needs to know about one asset.</summary>
    public sealed class GetlyAsset
    {
        /// <summary>Project-relative, e.g. Assets/Acme/Kit/Player.cs</summary>
        public string Path;
        public bool IsFolder;
        public bool HasMetaFile;
        public long SizeBytes;
        /// <summary>Source text, for the formats we read. Null otherwise.</summary>
        public string Text;
        /// <summary>Set by the editor-side collector for textures. 0 when unknown.</summary>
        public int TextureWidth;
        public int TextureHeight;
        public bool TextureCompressed;
        /// <summary>Prefabs and scenes that reference a script we cannot resolve.</summary>
        public bool HasMissingScriptReference;
        /// <summary>Folders only: whether anything lives inside.</summary>
        public bool IsEmptyFolder;
    }

    /// <summary>
    /// The rules, as pure functions over collected data.
    ///
    /// Deliberately free of UnityEditor: everything the editor knows arrives on
    /// <see cref="GetlyAsset"/>, so these rules can be exercised without opening
    /// Unity at all. The collector that fills those fields is the part that
    /// needs the editor, and it is kept as thin as it can be for that reason.
    ///
    /// The ids match src/lib/publisher/checks.json exactly. Where the server can
    /// see the same thing, both must call it the same thing — a seller who is
    /// told two different names for one problem stops believing either.
    /// </summary>
    public static class GetlyValidator
    {
        private const int MaxTextureSize = 2048;
        private const long LargePackageBytes = 500L * 1024 * 1024;
        private const long UncompressedTextureFloor = 2 * 1024 * 1024;

        /// <summary>
        /// A path rooted in the author's own home folder.
        ///
        /// Requires a user segment AND something after it, so prose that merely
        /// NAMES the shape of the problem is not itself reported. The looser
        /// version flagged the text of this very check inside our own tool —
        /// the same false positive any README explaining the rule would hit.
        /// </summary>
        private static readonly Regex AbsolutePath =
            new Regex(@"(?:^|[""'\s(=])(?:[A-Za-z]:\\Users\\[A-Za-z0-9._-]+\\|/Users/[A-Za-z0-9._-]+/|/home/[a-z0-9._-]+/)[A-Za-z0-9._-]",
                RegexOptions.Compiled);

        /// <summary>Formats where a path is being DISCUSSED rather than used.</summary>
        private static readonly HashSet<string> ProseExtensions =
            new HashSet<string>(StringComparer.OrdinalIgnoreCase) { ".md", ".txt", ".rtf", ".html" };

        private static readonly Regex UsingUnityEditor =
            new Regex(@"\busing\s+UnityEditor\b", RegexOptions.Compiled);

        private static readonly Regex EditorGuard =
            new Regex(@"#if\s+UNITY_EDITOR", RegexOptions.Compiled);

        private static readonly Regex NamespaceDecl =
            new Regex(@"^\s*namespace\s+[A-Za-z_]", RegexOptions.Compiled | RegexOptions.Multiline);

        private static readonly Regex TypeDecl =
            new Regex(@"^\s*(?:public|internal)?\s*(?:sealed\s+|abstract\s+|static\s+|partial\s+)*(?:class|struct|interface|enum)\s+",
                RegexOptions.Compiled | RegexOptions.Multiline);

        private static readonly string[] JunkNames =
            { ".DS_Store", "Thumbs.db", "desktop.ini" };

        private static readonly string[] JunkFolders =
            { "/.git/", "/.idea/", "/__MACOSX/", "/.vs/" };

        public static List<GetlyFinding> Validate(IReadOnlyList<GetlyAsset> assets)
        {
            var findings = new List<GetlyFinding>();
            if (assets == null || assets.Count == 0)
            {
                findings.Add(new GetlyFinding("empty_package"));
                return findings;
            }

            var files = assets.Where(a => !a.IsFolder).ToList();
            if (files.Count == 0)
            {
                findings.Add(new GetlyFinding("empty_package"));
                return findings;
            }

            foreach (var asset in assets)
            {
                // The GUID lives in the .meta. Without it the buyer's project
                // regenerates a new one and every reference to this asset breaks.
                if (!asset.HasMetaFile) findings.Add(new GetlyFinding("missing_meta", asset.Path));

                if (IsJunk(asset.Path)) findings.Add(new GetlyFinding("junk_files", asset.Path));

                if (asset.IsFolder && asset.IsEmptyFolder)
                    findings.Add(new GetlyFinding("empty_folder", asset.Path));
            }

            foreach (var file in files)
            {
                // The single most common reason an asset gets refunded.
                if (file.HasMissingScriptReference)
                    findings.Add(new GetlyFinding("missing_script_reference", file.Path));

                if (IsCompiledLibrary(file.Path))
                    findings.Add(new GetlyFinding("third_party_binary", file.Path));

                if (file.TextureWidth > MaxTextureSize || file.TextureHeight > MaxTextureSize)
                    findings.Add(new GetlyFinding("oversized_texture", file.Path,
                        file.TextureWidth + "x" + file.TextureHeight));

                if (file.TextureWidth > 0 && !file.TextureCompressed && file.SizeBytes > UncompressedTextureFloor)
                    findings.Add(new GetlyFinding("uncompressed_texture", file.Path));

                if (file.Text == null) continue;

                // Works perfectly on the author's machine and nowhere else,
                // which is precisely why it survives their own testing.
                // Documentation is exempt: a README telling the reader to avoid
                // these is doing the right thing, not the wrong one.
                if (!IsProse(file.Path) && AbsolutePath.IsMatch(file.Text))
                    findings.Add(new GetlyFinding("absolute_path", file.Path));

                if (!file.Path.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) continue;

                // UnityEditor does not exist in a built player.
                if (!IsInEditorFolder(file.Path)
                    && UsingUnityEditor.IsMatch(file.Text)
                    && !EditorGuard.IsMatch(file.Text))
                {
                    findings.Add(new GetlyFinding("editor_code_in_runtime", file.Path));
                }

                // Two assets that both define PlayerController globally cannot
                // coexist in one project.
                if (!NamespaceDecl.IsMatch(file.Text) && TypeDecl.IsMatch(file.Text))
                    findings.Add(new GetlyFinding("no_namespace", file.Path));
            }

            findings.AddRange(PackageWide(files));
            return findings;
        }

        private static IEnumerable<GetlyFinding> PackageWide(IReadOnlyList<GetlyAsset> files)
        {
            var results = new List<GetlyFinding>();
            var lower = files.Select(f => f.Path.ToLowerInvariant()).ToList();

            // Everything under one top-level folder, so the buyer can remove the
            // asset later without a manual hunt through their project.
            var roots = new HashSet<string>();
            var loose = 0;
            foreach (var file in files)
            {
                if (!file.Path.StartsWith("Assets/", StringComparison.Ordinal)) continue;
                var rest = file.Path.Substring("Assets/".Length);
                var slash = rest.IndexOf('/');
                if (slash < 0) loose++;
                else roots.Add(rest.Substring(0, slash));
            }
            if (loose > 0 || roots.Count > 1)
            {
                results.Add(new GetlyFinding("not_in_own_folder", null,
                    roots.Count > 1 ? roots.Count + " top-level folders" : loose + " loose file(s) in Assets/"));
            }

            if (!lower.Any(IsDocumentation)) results.Add(new GetlyFinding("no_readme"));
            if (!lower.Any(IsLicence)) results.Add(new GetlyFinding("no_license_file"));
            if (!lower.Any(p => p.EndsWith(".unity", StringComparison.Ordinal)))
                results.Add(new GetlyFinding("no_demo_scene"));

            var total = files.Sum(f => f.SizeBytes);
            if (total > LargePackageBytes)
                results.Add(new GetlyFinding("package_too_large", null, (total / (1024 * 1024)) + " MB"));

            return results;
        }

        private static bool IsDocumentation(string lowerPath)
        {
            var name = FileName(lowerPath);
            if (!name.StartsWith("readme") && !name.StartsWith("documentation") && !name.StartsWith("doc"))
                return false;
            return name.EndsWith(".md") || name.EndsWith(".txt") || name.EndsWith(".pdf")
                   || name.EndsWith(".rtf") || name.EndsWith(".html");
        }

        private static bool IsLicence(string lowerPath)
        {
            var name = FileName(lowerPath);
            return name.StartsWith("license") || name.StartsWith("licence");
        }

        private static string FileName(string path)
        {
            var slash = path.LastIndexOf('/');
            return slash < 0 ? path : path.Substring(slash + 1);
        }

        private static bool IsJunk(string path)
        {
            var name = FileName(path);
            foreach (var junk in JunkNames)
                if (string.Equals(name, junk, StringComparison.OrdinalIgnoreCase)) return true;
            var padded = "/" + path.Replace('\\', '/') + "/";
            foreach (var folder in JunkFolders)
                if (padded.IndexOf(folder, StringComparison.OrdinalIgnoreCase) >= 0) return true;
            return false;
        }

        private static bool IsProse(string path)
        {
            var dot = path.LastIndexOf('.');
            return dot >= 0 && ProseExtensions.Contains(path.Substring(dot));
        }

        private static bool IsCompiledLibrary(string path)
        {
            var name = FileName(path).ToLowerInvariant();
            return name.EndsWith(".dll") || name.EndsWith(".so") || name.EndsWith(".dylib")
                   || name.EndsWith(".a") || name.EndsWith(".jar");
        }

        /// <summary>
        /// Editor/ anywhere in the path is Unity's own rule for editor-only code,
        /// so a script there may reference UnityEditor freely.
        /// </summary>
        private static bool IsInEditorFolder(string path)
        {
            var padded = "/" + path.Replace('\\', '/') + "/";
            return padded.IndexOf("/Editor/", StringComparison.OrdinalIgnoreCase) >= 0;
        }

        public static bool HasBlocking(IEnumerable<GetlyFinding> findings)
        {
            return findings.Any(f => f.IsBlocking);
        }
    }
}
