// Asset Naming Manager — the entry point a build server calls.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using UnityEditor;

namespace AssetNamingManager
{
    /// <summary>
    /// The pull request gate. Scans the project with the active rule set, writes one report and
    /// exits with a code the build server can branch on.
    /// <para>
    /// Unity -batchmode -quit -nographics -logFile - -projectPath .
    /// -executeMethod AssetNamingManager.NamingManagerCI.Run
    /// -namingManagerReport Reports/naming.html -namingManagerFailOn error
    /// </para>
    /// Report-only by design: nothing here renames or moves an asset. A rename is a decision, so CI
    /// reports it and a person presses the button. See Documentation/CI-Integration.md.
    /// </summary>
    public static class NamingManagerCI
    {
        /// <summary>Exit code when nothing at or above the gate severity was found.</summary>
        public const int ExitClean = 0;

        /// <summary>Exit code when the gate failed. This is a naming problem, not a broken job.</summary>
        public const int ExitGateFailed = 1;

        /// <summary>
        /// Exit code for a misconfigured run: no rule set, an invalid rule set, a bad argument or an
        /// unwritable report path. A job that silently passes because the rule set went missing is
        /// worse than no job at all, so this never collapses into <see cref="ExitGateFailed"/>.
        /// </summary>
        public const int ExitMisconfigured = 2;

        /// <summary>Where to write the report, relative to the project root.</summary>
        public const string ReportSwitch = "-namingManagerReport";

        /// <summary>The lowest severity that fails the build: info, warning, error or none.</summary>
        public const string FailOnSwitch = "-namingManagerFailOn";

        const string FallbackReportFolder = "NamingManagerReports";

        /// <summary>
        /// Runs the gate and exits the editor. Point <c>-executeMethod</c> here. Never throws: any
        /// unexpected failure is logged and reported as <see cref="ExitMisconfigured"/>, because an
        /// exception escaping into batch mode would be indistinguishable from a naming failure.
        /// </summary>
        public static void Run()
        {
            int code;

            try
            {
                code = Execute();
            }
            catch (Exception exception)
            {
                Fail("The CI run failed before it could reach a verdict. This is a job problem, not " +
                     "a naming problem.\n" + exception);
                code = ExitMisconfigured;
            }

            EditorApplication.Exit(code);
        }

        // ---------------------------------------------------------------- the run

        static int Execute()
        {
            string reportArgument;
            string gateArgument;
            string problem;

            if (!TryParseArguments(Environment.GetCommandLineArgs(), out reportArgument, out gateArgument, out problem))
            {
                Fail(problem);
                return ExitMisconfigured;
            }

            NamingRuleSet ruleSet = NamingManagerSettings.ActiveRuleSet;
            if (ruleSet == null)
            {
                Fail("No rule set. This project has no NamingRuleSet asset, so there is no convention " +
                     "to check against. Create one from " + NamingManagerAbout.MenuRoot + "Create Rule Set, " +
                     "then commit " + NamingManagerSettings.DefaultRuleSetFolder + "/" +
                     NamingManagerSettings.DefaultRuleSetName + " along with the active rule set setting in " +
                     "Edit > Project Settings > " + NamingManagerAbout.ProductName + ".");
                return ExitMisconfigured;
            }

            List<string> configProblems = ruleSet.Validate();
            if (configProblems.Count > 0)
            {
                // A rule set that cannot produce a meaningful verdict is a build infrastructure
                // problem. Reporting it as a clean run would hide the outage for months.
                foreach (var configProblem in configProblems) Fail(ruleSet.name + ": " + configProblem);
                return ExitMisconfigured;
            }

            NamingSeverity? gate;
            string gateSource;

            if (gateArgument != null)
            {
                if (!TryParseGate(gateArgument, out gate, out problem))
                {
                    Fail(problem);
                    return ExitMisconfigured;
                }
                gateSource = FailOnSwitch;
            }
            else
            {
                // Documented fallback: with no switch the rule set's own ciFailOn decides, so the
                // gate travels with the convention instead of living in the CI job's YAML.
                gate = ruleSet.ciFailOn;
                gateSource = ruleSet.name + ".ciFailOn";
            }

            string reportPath;
            ReportFormat format;
            if (!TryResolveReport(reportArgument, ruleSet, out reportPath, out format, out problem))
            {
                Fail(problem);
                return ExitMisconfigured;
            }

            Log("Scanning with " + ruleSet.name + " — gate " + Describe(gate) + " (from " + gateSource +
                "), writing " + format + " to " + reportPath);

            // No progress callback and no dialogs: anything that waits for input hangs the build.
            ScanReport report = NamingScanner.Scan(ruleSet, new ScanOptions
            {
                includeFolders = true,
                includeDuplicates = true,
                onProgress = null
            });

            string written;
            try
            {
                // ReportExporter creates the folder, which is what makes the reportFolder default
                // usable on a fresh checkout that has never had a report in it.
                written = ReportExporter.Write(report, format, reportPath);
            }
            catch (Exception exception)
            {
                Fail("Could not write the report to " + reportPath + " — " + exception.Message +
                     " Keep the report outside Assets/ and make sure the job can write there.");
                return ExitMisconfigured;
            }

            Log("Report written to " + written);
            Log(report.Summary());

            foreach (var pair in ScanReport.Sorted(report.CountBy(RuleLabel), 5))
                Log("  " + pair.Value + " x " + pair.Key);

            bool failed = gate.HasValue && report.FailsGate(gate.Value);
            int code = failed ? ExitGateFailed : ExitClean;

            Log(report.ErrorCount + " errors, " + report.WarningCount + " warnings, " +
                report.InfoCount + " info (worst " + Describe(report.WorstSeverity) + ") — gate " +
                Describe(gate) + " — " + (failed ? "FAILED" : "passed") + ", exiting " + code +
                ". Health " + report.HealthScore.ToString("0", CultureInfo.InvariantCulture) + "/100 (" +
                report.HealthLabel + ").");

            return code;
        }

        // ---------------------------------------------------------------- arguments

        static bool TryParseArguments(string[] args, out string reportPath, out string gateName, out string problem)
        {
            reportPath = null;
            gateName = null;
            problem = null;
            if (args == null) return true;

            for (int i = 0; i < args.Length; i++)
            {
                string arg = args[i];
                if (string.IsNullOrEmpty(arg)) continue;

                bool isReport = string.Equals(arg, ReportSwitch, StringComparison.OrdinalIgnoreCase);
                bool isGate = string.Equals(arg, FailOnSwitch, StringComparison.OrdinalIgnoreCase);
                if (!isReport && !isGate) continue;

                string value = i + 1 < args.Length ? args[i + 1] : null;

                // A switch swallowing the next switch is the classic CI typo. Catching it beats
                // writing a report to a file called "-quit" and calling the build clean.
                if (string.IsNullOrEmpty(value) || value[0] == '-')
                {
                    problem = arg + " needs a value. Example: " +
                              (isReport ? ReportSwitch + " Reports/naming.html" : FailOnSwitch + " error");
                    return false;
                }

                if (isReport) reportPath = value;
                else gateName = value;

                i++;   // the value belongs to this switch
            }

            return true;
        }

        static bool TryParseGate(string value, out NamingSeverity? gate, out string problem)
        {
            gate = null;
            problem = null;

            switch (value.Trim().ToLowerInvariant())
            {
                case "info":    gate = NamingSeverity.Info;    return true;
                case "warning": gate = NamingSeverity.Warning; return true;
                case "error":   gate = NamingSeverity.Error;   return true;

                // Report only. The scan still runs and the report is still written, so the step can
                // publish an artifact on a branch where nothing is allowed to block a merge yet.
                case "none":    gate = null;                   return true;

                default:
                    problem = "'" + value + "' is not a severity. " + FailOnSwitch +
                              " takes info, warning, error or none. It names the LOWEST severity that " +
                              "fails the build, so 'error' is the loosest gate and 'info' the strictest.";
                    return false;
            }
        }

        // ---------------------------------------------------------------- the report path

        static bool TryResolveReport(string argument, NamingRuleSet ruleSet,
                                     out string fullPath, out ReportFormat format, out string problem)
        {
            problem = null;

            if (string.IsNullOrEmpty(argument))
            {
                // No switch: fall back to the rule set's report folder, the same place the window
                // writes its exports, with a timestamped name so yesterday's run survives.
                format = ReportFormat.Html;

                string folder = string.IsNullOrWhiteSpace(ruleSet.reportFolder)
                    ? FallbackReportFolder
                    : ruleSet.reportFolder.Trim();

                fullPath = Path.Combine(Resolve(folder), ReportExporter.SuggestFileName(format, DateTime.Now));
                return true;
            }

            if (!TryFormatFor(argument, out format))
            {
                fullPath = null;
                problem = ReportSwitch + " " + argument + " has no recognised extension. The format is " +
                          "taken from the file extension, one of " + SupportedExtensions() + ".";
                return false;
            }

            fullPath = Resolve(argument);
            return true;
        }

        static bool TryFormatFor(string path, out ReportFormat format)
        {
            format = ReportFormat.Html;

            string extension = Path.GetExtension(path);
            if (string.IsNullOrEmpty(extension)) return false;

            // Asking the exporter keeps one mapping instead of two that drift apart.
            foreach (ReportFormat candidate in AllFormats())
            {
                if (!string.Equals(ReportExporter.ExtensionFor(candidate), extension, StringComparison.OrdinalIgnoreCase))
                    continue;

                format = candidate;
                return true;
            }

            return false;
        }

        static string SupportedExtensions()
        {
            var extensions = new List<string>();
            foreach (ReportFormat candidate in AllFormats()) extensions.Add(ReportExporter.ExtensionFor(candidate));
            extensions.Sort(StringComparer.Ordinal);
            return string.Join(", ", extensions.ToArray());
        }

        static ReportFormat[] AllFormats()
        {
            return (ReportFormat[])Enum.GetValues(typeof(ReportFormat));
        }

        static string Resolve(string path)
        {
            if (Path.IsPathRooted(path)) return path;

            // Every documented path is relative to the project root — the folder that holds Assets/,
            // not the working directory, which on a build agent is wherever the shell happened to be.
            string root = Path.GetDirectoryName(UnityEngine.Application.dataPath);
            return string.IsNullOrEmpty(root) ? path : Path.Combine(root, path);
        }

        // ---------------------------------------------------------------- logging

        static string RuleLabel(AssetNamingViolation violation)
        {
            return string.IsNullOrEmpty(violation.ruleName) ? "Project-wide" : violation.ruleName;
        }

        static string Describe(NamingSeverity? severity)
        {
            return severity.HasValue ? severity.Value.ToString() : "none";
        }

        static void Log(string message)
        {
            UnityEngine.Debug.Log(NamingManagerAbout.LogPrefix + message);
        }

        static void Fail(string message)
        {
            UnityEngine.Debug.LogError(NamingManagerAbout.LogPrefix + message);
        }
    }
}
