using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;

namespace Davanci.Nexus.Editor
{
    // Project validator UI. Three tabs: Checks (settings/performance/native, issues first,
    // passed checks collapsed), Assets (on-demand import audit) and Guide (integration
    // snippets). Fix-all buttons show a live count and disappear once nothing is fixable.
    public class NexusDoctorWindow : EditorWindow
    {
        static readonly Color Bg      = new Color(0.10f, 0.11f, 0.14f);
        static readonly Color Panel   = new Color(0.13f, 0.14f, 0.18f);
        static readonly Color PanelHi = new Color(0.17f, 0.19f, 0.24f);
        static readonly Color Accent  = new Color(1f, 0.42f, 0.42f);
        static readonly Color OkGreen = new Color(0.40f, 0.80f, 0.45f);
        static readonly Color Todo    = new Color(0.90f, 0.65f, 0.30f);
        static readonly Color Muted   = new Color(0.62f, 0.64f, 0.70f);

        const int MaxRowsPerCategory = 60;
        const string LogoPath = "Assets/DavanciNexus/Art/logo-icon-cube.png";

        enum Tab { Checks, Assets, Guide }

        Tab _tab = Tab.Checks;
        VisualElement _tabBar;
        VisualElement _body;
        Label _healthBanner;
        List<AssetIssue> _assetIssues;   // null until a scan is run

        // Cached per refresh so the tab dots and banner agree with the cards.
        List<(string Group, List<DoctorCheck> Checks)> _checkGroups;

        static NexusDoctorWindow _open;

        [MenuItem("Davanci Nexus/Davanci Doctor")]
        public static void Open()
        {
            var w = GetWindow<NexusDoctorWindow>();
            w.titleContent = new GUIContent("Davanci Doctor",
                AssetDatabase.LoadAssetAtPath<Texture2D>(LogoPath));
            w.minSize = new Vector2(520, 640);
            w.Show();
        }

        public static void RefreshIfOpen()
        {
            if (_open != null)
                _open.Refresh();
        }

        void OnEnable()
        {
            _open = this;
            var root = rootVisualElement;
            root.Clear();
            root.style.backgroundColor = Bg;

            var header = new VisualElement();
            header.style.flexDirection = FlexDirection.Row;
            header.style.alignItems = Align.Center;
            header.style.flexShrink = 0;
            header.style.paddingLeft = 18; header.style.paddingRight = 18;
            header.style.paddingTop = 16; header.style.paddingBottom = 4;

            var logo = AssetDatabase.LoadAssetAtPath<Texture2D>(LogoPath);
            if (logo != null)
            {
                var logoImg = new Image { image = logo, scaleMode = ScaleMode.ScaleToFit };
                logoImg.style.width = 44;
                logoImg.style.height = 44;
                logoImg.style.flexShrink = 0;
                logoImg.style.marginRight = 10;
                header.Add(logoImg);
            }

            var titleCol = new VisualElement();
            var title = new Label("Davanci Doctor");
            title.style.fontSize = 20;
            title.style.unityFontStyleAndWeight = FontStyle.Bold;
            title.style.color = Accent;
            var subtitle = new Label("Mobile build settings & asset import audit");
            subtitle.style.fontSize = 11;
            subtitle.style.color = Muted;
            subtitle.style.marginTop = 2;
            titleCol.Add(title);
            titleCol.Add(subtitle);
            header.Add(titleCol);
            root.Add(header);

            // Overall health, always visible above the tabs.
            _healthBanner = new Label("");
            _healthBanner.style.fontSize = 11;
            _healthBanner.style.unityFontStyleAndWeight = FontStyle.Bold;
            _healthBanner.style.flexShrink = 0;
            _healthBanner.style.marginLeft = 18; _healthBanner.style.marginRight = 18;
            _healthBanner.style.marginTop = 6; _healthBanner.style.marginBottom = 4;
            root.Add(_healthBanner);

            _tabBar = new VisualElement();
            _tabBar.style.flexDirection = FlexDirection.Row;
            _tabBar.style.flexShrink = 0;
            _tabBar.style.paddingLeft = 18; _tabBar.style.paddingRight = 18;
            _tabBar.style.marginTop = 4; _tabBar.style.marginBottom = 8;
            root.Add(_tabBar);

            var scroll = new ScrollView();
            scroll.style.flexGrow = 1;
            scroll.style.paddingLeft = 18; scroll.style.paddingRight = 18;
            scroll.style.paddingBottom = 14;
            _body = new VisualElement();
            scroll.Add(_body);
            root.Add(scroll);

            Refresh();
        }

        void OnDisable()
        {
            if (_open == this) _open = null;
        }

        void Refresh()
        {
            _checkGroups = new List<(string, List<DoctorCheck>)>
            {
                ("Project settings", NexusDoctor.RunAll()),
                ("Performance & build size", NexusDoctor.RunPerformanceChecks()),
                ("Native dependencies", NexusDoctor.RunNativeChecks()),
            };

            UpdateHealthBanner();
            BuildTabBar();

            _body.Clear();
            switch (_tab)
            {
                case Tab.Checks: BuildChecksTab(); break;
                case Tab.Assets: BuildAssetsTab(); break;
                case Tab.Guide:  BuildGuideTab(); break;
            }
        }

        // --- health summary ---

        void UpdateHealthBanner()
        {
            var all = _checkGroups.SelectMany(g => g.Checks).ToList();
            int errors = all.Count(c => c.Status == DoctorStatus.Error);
            int warnings = all.Count(c => c.Status == DoctorStatus.Warning);

            if (errors > 0)
            {
                _healthBanner.text = $"{errors} error(s) and {warnings} warning(s) found. Fix the errors before shipping.";
                _healthBanner.style.color = Accent;
            }
            else if (warnings > 0)
            {
                _healthBanner.text = $"No blockers. {warnings} warning(s) to review.";
                _healthBanner.style.color = Todo;
            }
            else
            {
                _healthBanner.text = $"Healthy. All {all.Count} checks passed.";
                _healthBanner.style.color = OkGreen;
            }
        }

        // Worst status across the check groups, used for the Checks tab dot.
        DoctorStatus ChecksWorstStatus()
        {
            var all = _checkGroups.SelectMany(g => g.Checks);
            if (all.Any(c => c.Status == DoctorStatus.Error)) return DoctorStatus.Error;
            if (all.Any(c => c.Status == DoctorStatus.Warning)) return DoctorStatus.Warning;
            return DoctorStatus.Ok;
        }

        // --- tab bar ---

        void BuildTabBar()
        {
            _tabBar.Clear();
            AddTabButton(Tab.Checks, "Checks", StatusColor(ChecksWorstStatus()));

            Color assetDot;
            if (_assetIssues == null)
                assetDot = Muted;                       // not scanned yet
            else if (_assetIssues.Any(i => !i.Optional))
                assetDot = Todo;                        // fixes pending
            else
                assetDot = OkGreen;
            AddTabButton(Tab.Assets, "Assets", assetDot);

            AddTabButton(Tab.Guide, "Guide", default, showDot: false);
        }

        void AddTabButton(Tab tab, string text, Color dotColor, bool showDot = true)
        {
            bool active = _tab == tab;

            var b = new Button(() => { _tab = tab; Refresh(); });
            b.style.flexGrow = 1;
            b.style.height = 30;
            b.style.marginRight = tab == Tab.Guide ? 0 : 6;
            b.style.flexDirection = FlexDirection.Row;
            b.style.justifyContent = Justify.Center;
            b.style.alignItems = Align.Center;
            b.style.backgroundColor = active ? Accent : Panel;
            Round(b, 6); NoBorder(b);

            if (showDot)
            {
                var dot = new VisualElement();
                dot.style.width = 8; dot.style.height = 8;
                dot.style.flexShrink = 0;
                dot.style.marginRight = 6;
                dot.style.backgroundColor = dotColor;
                Round(dot, 4);
                b.Add(dot);
            }

            var label = new Label(text);
            label.style.fontSize = 12;
            label.style.unityFontStyleAndWeight = active ? FontStyle.Bold : FontStyle.Normal;
            label.style.color = active ? Color.white : Muted;
            b.Add(label);

            _tabBar.Add(b);
        }

        // --- Checks tab: issues first, passed checks collapsed ---

        void BuildChecksTab()
        {
            var fixable = _checkGroups
                .SelectMany(g => g.Checks)
                .Where(c => c.Status != DoctorStatus.Ok && c.Fix != null)
                .ToList();

            // Only offer a fix-all when there is actually something to fix.
            if (fixable.Count > 0)
            {
                var fixAll = Primary($"Fix all issues ({fixable.Count})", () =>
                {
                    foreach (var c in fixable) c.Fix();
                    AssetDatabase.SaveAssets();
                    Refresh();
                });
                fixAll.style.marginBottom = 10;
                _body.Add(fixAll);
            }

            int totalPassed = 0;
            foreach (var (group, checks) in _checkGroups)
            {
                var issues = checks.Where(c => c.Status != DoctorStatus.Ok).ToList();
                totalPassed += checks.Count - issues.Count;
                if (issues.Count == 0) continue;

                _body.Add(SectionTitle(group));
                foreach (var check in issues)
                    _body.Add(SettingCard(check));
            }

            // Passed checks: one compact line each, tucked into a foldout so issues stay
            // front and center.
            if (totalPassed > 0)
            {
                var fold = new Foldout { value = false, text = $"Passed checks ({totalPassed})" };
                StyleFoldout(fold, OkGreen);

                foreach (var (group, checks) in _checkGroups)
                {
                    foreach (var check in checks.Where(c => c.Status == DoctorStatus.Ok))
                        fold.Add(PassedRow(group, check));
                }
                fold.style.marginTop = 8;
                _body.Add(fold);
            }

            var rescan = Secondary("Re-scan", Refresh);
            rescan.style.marginTop = 10;
            rescan.style.alignSelf = Align.FlexStart;
            rescan.style.paddingLeft = 14; rescan.style.paddingRight = 14;
            _body.Add(rescan);
        }

        VisualElement PassedRow(string group, DoctorCheck check)
        {
            var row = Row();
            row.style.alignItems = Align.Center;
            row.style.paddingTop = 3; row.style.paddingBottom = 3;
            row.style.paddingLeft = 4;

            var dot = new VisualElement();
            dot.style.width = 6; dot.style.height = 6; dot.style.flexShrink = 0;
            dot.style.marginRight = 8;
            dot.style.backgroundColor = OkGreen;
            Round(dot, 3);
            row.Add(dot);

            var title = new Label(check.Title);
            title.style.fontSize = 11;
            title.style.color = Color.white;
            title.style.flexShrink = 0;
            row.Add(title);

            var msg = new Label("   " + check.Message);
            msg.style.fontSize = 10;
            msg.style.color = Muted;
            msg.style.flexShrink = 1;
            msg.style.overflow = Overflow.Hidden;
            msg.style.textOverflow = TextOverflow.Ellipsis;
            msg.style.whiteSpace = WhiteSpace.NoWrap;
            row.Add(msg);

            return row;
        }

        VisualElement SettingCard(DoctorCheck check)
        {
            var card = Card();
            card.Add(StatusHeader(check.Title, check.Status));

            var msg = new Label(check.Message);
            msg.style.fontSize = 10;
            msg.style.color = Muted;
            msg.style.whiteSpace = WhiteSpace.Normal;
            msg.style.marginTop = 4;
            card.Add(msg);

            if (check.Status != DoctorStatus.Ok && check.Fix != null)
            {
                var fix = Secondary(check.FixLabel, () =>
                {
                    check.Fix();
                    AssetDatabase.SaveAssets();
                    Refresh();
                });
                fix.style.marginTop = 6;
                fix.style.alignSelf = Align.FlexStart;
                fix.style.paddingLeft = 14; fix.style.paddingRight = 14;
                card.Add(fix);
            }
            return card;
        }

        // --- Assets tab ---

        void BuildAssetsTab()
        {
            _body.Add(Note("Scans only the assets your game ships: everything referenced by the scenes in Build Settings, plus Resources/ folders. Unused project assets are ignored."));

            if (!NexusAssetAuditor.CanScopeToGame)
            {
                var warn = new HelpBox(
                    "No scenes are enabled in Build Settings, so the scan covers the whole Assets folder. " +
                    "Add your game scenes in File > Build Settings to scope the audit to shipped assets only.",
                    HelpBoxMessageType.Warning);
                warn.style.marginBottom = 8;
                _body.Add(warn);
            }

            if (_assetIssues == null)
            {
                var scan = Primary("Scan assets", () =>
                {
                    _assetIssues = NexusAssetAuditor.Audit();
                    Refresh();
                });
                _body.Add(scan);
                return;
            }

            var safe = _assetIssues.Where(i => !i.Optional).ToList();
            var optional = _assetIssues.Where(i => i.Optional).ToList();

            if (safe.Count == 0)
            {
                var ok = new Label("No required fixes." + (optional.Count > 0 ? " Optional size savings below." : ""));
                ok.style.fontSize = 11;
                ok.style.unityFontStyleAndWeight = FontStyle.Bold;
                ok.style.color = OkGreen;
                ok.style.marginBottom = 8;
                _body.Add(ok);
            }
            else
            {
                var fixEverything = Primary($"Fix all asset issues ({safe.Count})", () =>
                {
                    NexusAssetAuditor.FixAll(safe);
                    _assetIssues = NexusAssetAuditor.Audit();
                    Refresh();
                });
                fixEverything.style.marginBottom = 8;
                _body.Add(fixEverything);

                BuildCategory("Textures", AssetCategory.Texture, safe);
                BuildCategory("Audio", AssetCategory.Audio, safe);
                BuildCategory("Models", AssetCategory.Model, safe);
            }

            if (optional.Count > 0)
            {
                _body.Add(SectionTitle("Optional: shrink download size"));
                _body.Add(Note("Lossy, build-size-only optimizations: they cut download size with a small quality cost and don't change runtime memory. Apply only where the trade-off is fine."));
                BuildCategory("Sprites", AssetCategory.Texture, optional);
                BuildCategory("Audio", AssetCategory.Audio, optional);
                BuildCategory("Meshes", AssetCategory.Model, optional);
            }

            var rescan = Secondary("Re-scan assets", () =>
            {
                _assetIssues = NexusAssetAuditor.Audit();
                Refresh();
            });
            rescan.style.marginTop = 4;
            _body.Add(rescan);
        }

        void BuildCategory(string title, AssetCategory category, List<AssetIssue> source)
        {
            var items = source.Where(i => i.Category == category).ToList();
            if (items.Count == 0) return;

            bool opt = items[0].Optional;
            var assets = items.GroupBy(i => i.Path).ToList();

            var fold = new Foldout { value = false, text = $"{title} ({assets.Count}){(opt ? "" : " to fix")}" };
            StyleFoldout(fold, Accent);

            var batch = Secondary($"{(opt ? "Optimize" : "Fix")} all {title.ToLower()} ({assets.Count})", () =>
            {
                NexusAssetAuditor.FixAll(items);
                _assetIssues = NexusAssetAuditor.Audit();
                Refresh();
            });
            batch.style.alignSelf = Align.FlexStart;
            batch.style.paddingLeft = 14; batch.style.paddingRight = 14;
            batch.style.marginTop = 4; batch.style.marginBottom = 8;
            fold.Add(batch);

            foreach (var asset in assets.Take(MaxRowsPerCategory))
                fold.Add(AssetCard(asset.Key, asset.ToList()));

            if (assets.Count > MaxRowsPerCategory)
                fold.Add(Note($"… and {assets.Count - MaxRowsPerCategory} more. Use \"Fix all {title.ToLower()}\" to handle them in one pass."));

            _body.Add(fold);
        }

        VisualElement AssetCard(string path, List<AssetIssue> issues)
        {
            var card = Card();

            var layout = Row();
            layout.style.alignItems = Align.FlexStart;
            layout.Add(PreviewImage(path));

            var info = new VisualElement();
            info.style.flexGrow = 1;
            info.style.flexShrink = 1;

            var top = Row();
            top.style.justifyContent = Justify.SpaceBetween;
            top.style.alignItems = Align.Center;

            var name = new Label(System.IO.Path.GetFileName(path));
            name.style.unityFontStyleAndWeight = FontStyle.Bold;
            name.style.fontSize = 12;
            name.style.flexShrink = 1;
            top.Add(name);

            var ping = Secondary("Select", () =>
            {
                var obj = AssetDatabase.LoadMainAssetAtPath(path);
                EditorGUIUtility.PingObject(obj);
                Selection.activeObject = obj;
            });
            ping.style.height = 22;
            ping.style.paddingLeft = 12; ping.style.paddingRight = 12;
            ping.style.flexShrink = 0;
            top.Add(ping);
            info.Add(top);

            var p = new Label(path);
            p.style.fontSize = 9;
            p.style.color = Muted;
            p.style.whiteSpace = WhiteSpace.Normal;
            info.Add(p);

            bool opt = issues.Count > 0 && issues[0].Optional;
            foreach (var issue in issues)
            {
                var detail = new Label("• " + issue.Detail);
                detail.style.fontSize = 10;
                detail.style.color = issue.Optional ? Muted : StatusColor(issue.Severity);
                detail.style.whiteSpace = WhiteSpace.Normal;
                detail.style.marginTop = 4;
                info.Add(detail);
            }

            string fixLabel = opt
                ? (issues.Count > 1 ? $"Optimize ({issues.Count})" : "Optimize")
                : (issues.Count > 1 ? $"Fix {issues.Count} issues" : "Fix");
            var fix = Secondary(fixLabel, () =>
            {
                NexusAssetAuditor.FixAll(issues);
                _assetIssues = NexusAssetAuditor.Audit();
                Refresh();
            });
            fix.style.marginTop = 6;
            fix.style.alignSelf = Align.FlexStart;
            fix.style.paddingLeft = 14; fix.style.paddingRight = 14;
            info.Add(fix);

            layout.Add(info);
            card.Add(layout);
            return card;
        }

        // --- Guide tab ---

        void BuildGuideTab()
        {
            var events = Card();
            events.Add(CardHeader("Analytics events"));
            events.Add(Note("Call from gameplay; every event is forwarded to all installed providers."));
            events.Add(CodeLine("DavanciNexus.TutorialStep(0, \"tap_to_start\");  DavanciNexus.TutorialStepDone(0);"));
            events.Add(CodeLine("DavanciNexus.Progress.Start(1);  DavanciNexus.Progress.Complete(1);"));
            events.Add(CodeLine("DavanciNexus.CurrencyEarned(\"coins\", 1500, \"gameplay\");"));
            events.Add(CodeLine("DavanciNexus.Custom(\"boss_defeated\", (\"world\", 2));"));
            _body.Add(events);

            var ratings = Card();
            ratings.Add(CardHeader("Ratings & feedback"));
            ratings.Add(Note("Trigger after a win: \"Do you like the game?\" — Yes opens the OS native review sheet, No stays private."));
            ratings.Add(CodeLine("NexusReviewPopup.TryShow();   // Yes → native review sheet, No → just thanks"));
            ratings.Add(CodeLine("NexusReview.TryAsk();   // native 5-star sheet, self-throttled"));
            ratings.Add(CodeLine("NexusReview.OpenStorePage(NexusConfig.Load().iosAppStoreId);"));
            ratings.Add(CodeLine("NexusFeedback.Submit(text);   // 👎 to Firebase Realtime DB"));
            _body.Add(ratings);

            var shots = Card();
            shots.Add(CardHeader("Store screenshots"));
            shots.Add(Note("Capture marketing screenshots at exact App Store / Play Store resolutions while the game runs."));
            var open = Secondary("Open Screenshot tool", NexusScreenshotTool.Open);
            open.style.alignSelf = Align.FlexStart;
            open.style.paddingLeft = 14; open.style.paddingRight = 14;
            open.style.marginTop = 4;
            shots.Add(open);
            _body.Add(shots);
        }

        Label CodeLine(string text)
        {
            var l = new Label(text);
            l.style.fontSize = 11;
            l.style.color = OkGreen;
            l.style.whiteSpace = WhiteSpace.Normal;
            l.style.marginBottom = 2;
            return l;
        }

        // Live asset thumbnail: rendered preview for textures/models, icon for the rest.
        // Previews generate async, so poll briefly until Unity has one ready.
        Image PreviewImage(string path)
        {
            var img = new Image { scaleMode = ScaleMode.ScaleToFit };
            img.style.width = 56; img.style.height = 56;
            img.style.flexShrink = 0;
            img.style.marginRight = 10;
            img.style.backgroundColor = new Color(0f, 0f, 0f, 0.25f);
            Round(img, 6);

            var obj = AssetDatabase.LoadMainAssetAtPath(path);
            if (obj == null) return img;

            var preview = AssetPreview.GetAssetPreview(obj);
            img.image = preview != null ? preview : AssetPreview.GetMiniThumbnail(obj);

            if (preview == null)
            {
                int tries = 0;
                img.schedule.Execute(() =>
                {
                    var ready = AssetPreview.GetAssetPreview(obj);
                    if (ready != null) img.image = ready;
                }).Every(120).Until(() => ++tries > 25 || AssetPreview.GetAssetPreview(obj) != null);
            }
            return img;
        }

        void StyleFoldout(Foldout f, Color color)
        {
            f.style.marginTop = 2;
            f.style.marginBottom = 8;
            var label = f.Q<Label>();
            if (label != null)
            {
                label.style.unityFontStyleAndWeight = FontStyle.Bold;
                label.style.fontSize = 12;
                label.style.color = color;
            }
        }

        VisualElement StatusHeader(string title, DoctorStatus status)
        {
            var row = Row();
            row.style.justifyContent = Justify.SpaceBetween;
            row.style.alignItems = Align.Center;

            var left = Row();
            left.style.alignItems = Align.Center;
            var dot = new VisualElement();
            dot.style.width = 8; dot.style.height = 8; dot.style.flexShrink = 0;
            dot.style.marginRight = 8;
            dot.style.backgroundColor = StatusColor(status);
            Round(dot, 4);
            left.Add(dot);
            var label = new Label(title);
            label.style.unityFontStyleAndWeight = FontStyle.Bold;
            label.style.fontSize = 12;
            left.Add(label);
            row.Add(left);

            var badge = new Label(status.ToString());
            badge.style.fontSize = 10;
            badge.style.unityFontStyleAndWeight = FontStyle.Bold;
            badge.style.color = StatusColor(status);
            row.Add(badge);
            return row;
        }

        Color StatusColor(DoctorStatus s) =>
            s == DoctorStatus.Ok ? OkGreen : (s == DoctorStatus.Warning ? Todo : Accent);

        static VisualElement Card()
        {
            var c = new VisualElement();
            c.style.backgroundColor = Panel;
            c.style.paddingLeft = 12; c.style.paddingRight = 12;
            c.style.paddingTop = 10; c.style.paddingBottom = 10;
            c.style.marginBottom = 8;
            Round(c, 8);
            return c;
        }

        static VisualElement Row()
        {
            var r = new VisualElement();
            r.style.flexDirection = FlexDirection.Row;
            return r;
        }

        static Label SectionTitle(string text)
        {
            var l = new Label(text);
            l.style.fontSize = 14;
            l.style.unityFontStyleAndWeight = FontStyle.Bold;
            l.style.marginTop = 8;
            l.style.marginBottom = 8;
            return l;
        }

        Label CardHeader(string text)
        {
            var l = new Label(text);
            l.style.unityFontStyleAndWeight = FontStyle.Bold;
            l.style.fontSize = 12;
            l.style.color = Accent;
            l.style.marginTop = 6;
            l.style.marginBottom = 4;
            return l;
        }

        static Label Note(string text)
        {
            var l = new Label(text);
            l.style.fontSize = 10;
            l.style.color = Muted;
            l.style.whiteSpace = WhiteSpace.Normal;
            l.style.marginBottom = 8;
            return l;
        }

        Button Primary(string text, System.Action onClick)
        {
            var b = new Button(onClick) { text = text };
            b.style.height = 30;
            b.style.color = Color.white;
            b.style.unityFontStyleAndWeight = FontStyle.Bold;
            Round(b, 6); NoBorder(b);
            Feedback(b, Accent);
            return b;
        }

        Button Secondary(string text, System.Action onClick)
        {
            var b = new Button(onClick) { text = text };
            b.style.height = 26;
            b.style.color = Color.white;
            Round(b, 6); NoBorder(b);
            Feedback(b, PanelHi);
            return b;
        }

        static void Round(VisualElement e, int r)
        {
            e.style.borderTopLeftRadius = r; e.style.borderTopRightRadius = r;
            e.style.borderBottomLeftRadius = r; e.style.borderBottomRightRadius = r;
        }

        static void NoBorder(VisualElement e)
        {
            e.style.borderTopWidth = 0; e.style.borderBottomWidth = 0;
            e.style.borderLeftWidth = 0; e.style.borderRightWidth = 0;
        }

        // Manual hover/press/disabled colors (inline styles override UI Toolkit's :hover/:active).
        static void Feedback(Button b, Color baseColor)
        {
            Color hover  = Shift(baseColor, 0.10f);
            Color active = Shift(baseColor, -0.14f);
            b.style.backgroundColor = baseColor;
            b.style.transitionProperty = new List<StylePropertyName> { "background-color" };
            b.style.transitionDuration = new List<TimeValue> { new TimeValue(80, TimeUnit.Millisecond) };

            bool hovering = false, pressed = false;
            void Apply()
            {
                if (!b.enabledSelf) { b.style.backgroundColor = new Color(baseColor.r, baseColor.g, baseColor.b, 0.45f); return; }
                b.style.backgroundColor = pressed ? active : (hovering ? hover : baseColor);
            }
            b.RegisterCallback<PointerEnterEvent>(_ => { hovering = true; Apply(); });
            b.RegisterCallback<PointerLeaveEvent>(_ => { hovering = false; pressed = false; Apply(); });
            b.RegisterCallback<PointerDownEvent>(_ => { pressed = true; Apply(); });
            b.RegisterCallback<PointerUpEvent>(_ => { pressed = false; Apply(); });
            b.RegisterCallback<AttachToPanelEvent>(_ => Apply());
        }

        static Color Shift(Color c, float amount) => new Color(
            Mathf.Clamp01(c.r + amount), Mathf.Clamp01(c.g + amount), Mathf.Clamp01(c.b + amount), c.a);
    }
}
