Editor/WeaponImporter/Core/Setup/Validation.cs
#nullable enable annotations

using WeaponImporter.Core.Analysis;
using WeaponImporter.Core.Grip;
using WeaponImporter.Core.Weapon;

namespace WeaponImporter.Core.Setup;

public enum CheckSeverity { Ok, Info, Warning, Error }

/// <summary>One validation line: "✓ Skeleton" or "! Left wrist penetration detected".</summary>
public sealed record CheckResult
{
    public required string Name { get; init; }
    public required CheckSeverity Severity { get; init; }
    public string Message { get; init; } = "";

    /// <summary>A safe automatic fix, when one exists.</summary>
    public Action? AutoFix { get; init; }
    public string AutoFixLabel { get; init; } = "Auto Fix";

    public bool NeedsAttention => Severity >= CheckSeverity.Warning;
}

/// <summary>Everything validation can look at; engine-side facts are optional.</summary>
public sealed record ValidationInput
{
    public required WeaponSetup Setup { get; init; }
    public WeaponAnalysis? Analysis { get; init; }
    public GripSolution? Grip { get; init; }

    /// <summary>Compile errors captured by the editor (file -> messages).</summary>
    public IReadOnlyDictionary<string, IReadOnlyList<string>>? CompileErrors { get; init; }

    /// <summary>Asset references the editor could not resolve.</summary>
    public IReadOnlyList<string> MissingReferences { get; init; } = Array.Empty<string>();

    /// <summary>Clip names present in the compiled model (null when not compiled yet).</summary>
    public IReadOnlyCollection<string>? CompiledSequences { get; init; }

    /// <summary>Problems found by replaying the grip over the character's actions (role, normalized time, message).</summary>
    public IReadOnlyList<(AnimationRole Role, float Time, string Message)> ActionIssues { get; init; } = Array.Empty<(AnimationRole, float, string)>();

    /// <summary>Called by auto fixes that change the setup, so the editor can re-solve.</summary>
    public Action? Changed { get; init; }
}

public static class Validation
{
    public const float SeverePenetration = 0.45f;

    public static List<CheckResult> Run(ValidationInput input)
    {
        var s = input.Setup;
        var a = input.Analysis;
        var results = new List<CheckResult>();
        void Add(string name, CheckSeverity sev, string msg = "", Action? fix = null, string fixLabel = "Auto Fix")
            => results.Add(new CheckResult { Name = name, Severity = sev, Message = msg, AutoFix = fix, AutoFixLabel = fixLabel });
        void Changed() => input.Changed?.Invoke();

        // Asset compiles.
        if (input.CompileErrors is { } errors)
        {
            if (errors.Count == 0)
                Add("Compiles", CheckSeverity.Ok);
            else
                foreach (var (file, msgs) in errors)
                    Add("Compiles", CheckSeverity.Error, $"{Path.GetFileName(file)}: {msgs.FirstOrDefault() ?? "compile failed"}");
        }

        // Skeleton / root.
        if (a is not null)
        {
            var skeleton = a.Asset.Skeleton;
            if (skeleton.Count == 0)
                Add("Skeleton", CheckSeverity.Error, "The model has no bones.");
            else if (skeleton.Bones.GroupBy(b => b.Name, StringComparer.OrdinalIgnoreCase).Any(g => g.Count() > 1))
                Add("Skeleton", CheckSeverity.Warning, "Bone names differ only by case; the engine may merge them.");
            else
                Add("Skeleton", CheckSeverity.Ok, $"{skeleton.Count} bones");

            if (string.IsNullOrEmpty(s.RootBone) || skeleton.IndexOf(s.RootBone) < 0)
                Add("Root", CheckSeverity.Error, $"Root bone '{s.RootBone}' is missing.", () => { s.RootBone = skeleton[a.RootBone].Name; Changed(); });
            else
                Add("Root", CheckSeverity.Ok, s.RootBone);

            var (min, max) = WeaponTypes.TypicalLength(s.Type);
            if (a.Length < min * 0.5f || a.Length > max * 2f)
                Add("Scale", CheckSeverity.Warning, $"{a.Length:0.#} in is unusual for a {WeaponTypes.Label(s.Type).ToLowerInvariant()}.");
        }

        // Animations.
        var clipNames = a?.Asset.Clips.Select(c => c.Name).ToHashSet(StringComparer.OrdinalIgnoreCase) ?? new HashSet<string>();
        var broken = s.WeaponAnimations.Where(kv => !string.IsNullOrEmpty(kv.Value.Clip) && a is not null && !clipNames.Contains(kv.Value.Clip)).ToList();
        foreach (var (role, binding) in broken)
            Add("Animations", CheckSeverity.Error, $"{AnimationRoles.Label(role)} uses '{binding.Clip}', which the model doesn't have.", () => { s.WeaponAnimations.Remove(role); Changed(); }, "Remove");
        if (input.CompiledSequences is { } compiled)
            foreach (var (role, binding) in s.WeaponAnimations.Where(kv => !compiled.Contains(EngineNames.Sequence(kv.Value.Clip))))
                Add("Animations", CheckSeverity.Error, $"{AnimationRoles.Label(role)} ('{binding.Clip}') did not compile into the model.");
        if (WeaponTypes.IsFirearm(s.Type) && a is { Asset.Clips.Count: > 0 })
        {
            var missing = AnimationRoles.Required
                .Where(r => !s.WeaponAnimations.ContainsKey(r) && !(AnimationRoles.Fallback(r) is { } f && s.WeaponAnimations.ContainsKey(f)))
                // A shell-by-shell reload is a reload.
                .Where(r => !(r == AnimationRole.Reload && s.WeaponAnimations.ContainsKey(AnimationRole.ReloadInsert)))
                .ToList();
            if (missing.Count > 0)
                Add("Animations", CheckSeverity.Warning, $"No {string.Join(", ", missing.Select(AnimationRoles.Label))} animation; the character's own animation plays instead.");
            else if (broken.Count == 0)
                Add("Animations", CheckSeverity.Ok, $"{s.WeaponAnimations.Count} mapped");
        }
        else if (broken.Count == 0)
        {
            Add("Animations", CheckSeverity.Ok, s.WeaponAnimations.Count == 0 ? "static weapon" : $"{s.WeaponAnimations.Count} mapped");
        }

        // Grips.
        if (!s.Primary.Enabled)
            Add("Right grip", CheckSeverity.Error, "No firing-hand grip.");
        else if (input.Grip is { } g)
        {
            var q = g.RightQuality;
            if (q.PalmPenetration > SeverePenetration || q.MaxPenetration > SeverePenetration * 1.5f)
                Add("Right grip", CheckSeverity.Warning, $"Right hand passes {MathF.Max(q.PalmPenetration, q.MaxPenetration):0.0} in into the weapon.");
            else if (q.TouchingFingers < 2)
                Add("Right grip", CheckSeverity.Warning, "Right fingers barely touch the grip.");
            else if (g.RightWristBend > GripSolver.MaxWristBend)
                Add("Right grip", CheckSeverity.Warning, $"Right wrist bends {g.RightWristBend:0}° against the forearm. Try Wrist vs aim or pick the grip lower.");
            else
                Add("Right grip", CheckSeverity.Ok, s.Primary.Manual ? "adjusted" : s.Primary.Reason);
        }
        else
            Add("Right grip", CheckSeverity.Ok, s.Primary.Reason);

        var wantsSupport = s.UseSupportHand && WeaponTypes.TwoHanded(s.Type);
        if (wantsSupport && s.Support is null)
            Add("Left grip", CheckSeverity.Warning, "No support-hand area found. Pick one on the weapon, or turn the support hand off.", () => { s.UseSupportHand = false; Changed(); }, "One-handed");
        else if (s.UseSupportHand && s.Support is not null && input.Grip is { Left: not null } lg)
        {
            var q = lg.LeftQuality!;
            if (!lg.LeftReach.Reached && lg.LeftReach.Shortfall > s.Ik.ReachSlack)
                Add("Left grip", CheckSeverity.Warning, $"Left hand can't reach the support grip ({lg.LeftReach.Shortfall:0.0} in short).");
            else if (q.PalmPenetration > SeverePenetration)
                Add("Left grip", CheckSeverity.Warning, "Left wrist penetration detected.");
            else if (lg.LeftWristBend > GripSolver.MaxWristBend)
                Add("Left grip", CheckSeverity.Warning, $"Left wrist bends {lg.LeftWristBend:0}° against the forearm.");
            else if (q.HandOverlap > 0.3f)
                Add("Left grip", CheckSeverity.Warning, "Left hand overlaps the right hand.");
            else if (q.MaxPenetration > SeverePenetration * 1.5f)
                Add("Left grip", CheckSeverity.Warning, $"Left fingers pass {q.MaxPenetration:0.0} in into the weapon.");
            else
                Add("Left grip", CheckSeverity.Ok, s.Support.Manual ? "adjusted" : s.Support.Reason);
        }
        else if (s.UseSupportHand && s.Support is not null)
            Add("Left grip", CheckSeverity.Ok, s.Support.Reason);

        // Keep-clear areas (muzzle, blade): no hand may hold them.
        if (a is not null)
        {
            var forbidden = Grip.GripRegions.Of(a).Where(r => r.Kind == Grip.GripRegionKind.Forbidden).ToList();
            foreach (var (name, grip) in new[] { ("Right grip", (GripSetup?)s.Primary), ("Left grip", s.UseSupportHand ? s.Support : null) })
                if (grip is not null && forbidden.FirstOrDefault(r => r.Contains(V.Of(grip.Contact), 0.25f)) is { } area)
                    Add(name, CheckSeverity.Warning, $"The {(name.StartsWith("Right") ? "right" : "left")} hand holds the {area.Reason}, which should stay clear.");
        }

        // Hands over the animations.
        foreach (var group in input.ActionIssues.GroupBy(i => i.Role))
        {
            var first = group.OrderBy(i => i.Time).First();
            Add("Hands in " + AnimationRoles.Label(group.Key), CheckSeverity.Warning, $"{first.Message} at {first.Time * 100:0}% of the animation.");
        }

        // Muzzle / eject.
        if (WeaponTypes.IsFirearm(s.Type))
        {
            if (s.Muzzle is null)
                Add("Muzzle", CheckSeverity.Error, "No muzzle point.", a?.Muzzle is { } m ? () => { s.Muzzle = AutoSetup.Point(a, m); Changed(); } : null, "Use detected");
            else if (a is not null && a.Asset.Skeleton.IndexOf(s.Muzzle.Bone) < 0)
                Add("Muzzle", CheckSeverity.Error, $"Muzzle bone '{s.Muzzle.Bone}' is missing.", () => { s.Muzzle.Bone = a.Asset.Skeleton[a.RootBone].Name; Changed(); });
            else
                Add("Muzzle", s.Muzzle.Confidence < 0.5f && !s.Muzzle.Manual ? CheckSeverity.Info : CheckSeverity.Ok, s.Muzzle.Manual ? "placed" : "detected");
            if (s.Type is not (WeaponType.Revolver or WeaponType.Launcher) && s.Eject is null)
                Add("Shell eject", CheckSeverity.Info, "No ejection point; shells won't spawn.");
        }

        // Events.
        foreach (var e in s.Events.Where(e => e.Time < 0f || e.Time > 1f || !s.WeaponAnimations.ContainsKey(e.Role)))
        {
            var ev = e;
            Add("Events", CheckSeverity.Warning, $"{WeaponEvent.Label(e.Kind)} on {AnimationRoles.Label(e.Role)} has no clip to live on.", () => { s.Events.Remove(ev); Changed(); }, "Remove");
        }
        foreach (var dup in s.Events.GroupBy(e => (e.Role, e.Kind, MathF.Round(e.Time, 3))).Where(g => g.Count() > 1))
        {
            var extra = dup.Skip(1).ToList();
            Add("Events", CheckSeverity.Info, $"{WeaponEvent.Label(dup.Key.Kind)} is listed twice on {AnimationRoles.Label(dup.Key.Role)}.", () => { foreach (var x in extra) s.Events.Remove(x); Changed(); }, "Remove duplicate");
        }

        // References.
        foreach (var missing in input.MissingReferences)
            Add("References", CheckSeverity.Error, $"Missing: {missing}");

        return results;
    }

    /// <summary>Only the lines that need a person, in order of severity.</summary>
    public static IEnumerable<CheckResult> Problems(IEnumerable<CheckResult> results)
        => results.Where(r => r.NeedsAttention).OrderByDescending(r => r.Severity);
}

/// <summary>Names as the engine sees them (ModelDoc sanitises sequence and bone names).</summary>
public static class EngineNames
{
    public static string Sanitize(string name)
    {
        var raw = name;
        var sep = raw.LastIndexOf('|');
        if (sep >= 0 && sep < raw.Length - 1)
            raw = raw[(sep + 1)..];
        var chars = raw.Select(c => char.IsLetterOrDigit(c) ? c : '_').ToArray();
        var s = new string(chars).Trim('_');
        return s.Length == 0 ? "anim" : s;
    }

    /// <summary>Sequence name the importer gives a source clip in the generated model.</summary>
    public static string Sequence(string clip) => Sanitize(clip).ToLowerInvariant();

    public static string Bone(string bone)
    {
        var hash = bone.IndexOf('#');
        if (hash >= 0)
            bone = bone[..hash];
        return new string(bone.Select(c => char.IsLetterOrDigit(c) || c == '_' ? c : '_').ToArray());
    }
}