UI/ContextMenu/GameObjectInspector.razor
@using Sandbox;
@using Sandbox.UI;
@attribute [InspectorEditor(null)]
@attribute [Order(100)]
@inherits Panel
@namespace Sandbox
@implements IInspectorEditor

<root>
    <div class="body">
            @if (Target == null || Target.Count == 0)
            {
            <div class="empty-state">#spawnmenu.inspect.click_to_inspect</div>
            }
            else
            {
                var totalMass = Target.SelectMany( go => go.GetComponentsInChildren<Rigidbody>() ).Sum( ResolveMass );
                var health = Target.Select( go => go.GetComponent<Prop>() ).FirstOrDefault( p => p.IsValid() );

                <div class="object-info">
                    @if ( totalMass > 0 )
                    {
                        <span>⚖️ @($"{totalMass:0.#} kg")</span>
                    }
                    @if ( health.IsValid() && health.Health != 0 )
                    {
                        <span>❤️ @($"{health.Health:0.#} HP")</span>
                    }
                </div>
                <ControlSheet Target="@Properties"></ControlSheet>

                @if (Renderers.Count > 0)
                {
                    @if ( MaterialGroups.Count > 1 )
                    {
                        var currentGroup = Renderers[0].MaterialGroup ?? MaterialGroups[0];
                        <div class="material-row">
                        <label>#spawnmenu.inspect.skin</label>
                            <div class="material-button" @onclick="@PickMaterialGroup">
                                <label>@currentGroup</label>
                                <label class="material-group-arrow">▾</label>
                            </div>
                        </div>
                    }

                    var accessor = Renderers[0].Materials;
                    @for (int i = 0; i < accessor.Count; i++)
                    {
                        var index = i;
                        var hasOverride = accessor.HasOverride(index);
                        var mat = hasOverride ? accessor.GetOverride(index) : accessor.GetOriginal(index);
                        var name = mat?.ResourceName ?? "Default";

                        <div class="material-row @(hasOverride ? "overridden" : "")">
                        <label>#spawnmenu.inspect.material</label><label> @(index + 1)</label>
                            <div class="material-button" @onclick=@(() => PickMaterial(index))>
                                <div class="material-preview" style="background-image: url( thumb:@(mat?.ResourcePath) )"></div>
                                <label>@name</label>
                            </div>
                            @if (hasOverride)
                            {
                                <div class="material-revert" @onclick=@(() => RevertMaterial(index))>x</div>
                            }
                        </div>
                    }
                }
            }
    </div>
</root>

@code
{
    public string Title => Target?.Count switch
    {
        null or < 2 => "📦 " + Game.Language.GetPhrase( "spawnmenu.inspect.object" ),
        _ => "📦 " + Game.Language.GetPhrase( "spawnmenu.inspect.object" ) + $" (+{Target.Count - 1})"
    };

    public List<GameObject> Target { get; private set; }

    public bool TrySetTarget(List<GameObject> selection)
    {
        var ids = selection.Select(x => x.Id);
        if (!ids.SequenceEqual(Target?.Select(x => x.Id) ?? []))
        {
            Target = selection.Any() ? selection.ToList() : null;
            RebuildFromTarget();
            StateHasChanged();
        }

        // Hide the tab when something is selected but there's nothing to show
        return Target == null || InspectorHasContent();
    }

    // Frozen rigidbodies report Mass = 0 from the live physics body, so fall back
    // to PhysicalProperties.Mass and MassOverride before giving up.
    static float ResolveMass( Rigidbody rb )
    {
        if ( !rb.IsValid() ) return 0f;
        var mo = rb.GetComponent<PhysicalProperties>();
        if ( mo.IsValid() && mo.Mass > 0f ) return mo.Mass;
        if ( rb.MassOverride > 0f ) return rb.MassOverride;
        return rb.Mass;
    }

    bool InspectorHasContent()
    {
        if ( Target == null ) return false;
        if ( Properties.Count > 0 || Renderers.Count > 0 ) return true;

        var totalMass = Target.SelectMany( go => go.GetComponentsInChildren<Rigidbody>() ).Sum( ResolveMass );
        if ( totalMass > 0 ) return true;

        var health = Target.Select( go => go.GetComponent<Prop>() ).FirstOrDefault( p => p.IsValid() );
        if ( health.IsValid() && health.Health != 0 ) return true;

        return false;
    }

    List<SerializedProperty> Properties = new();
    List<ModelRenderer> Renderers = new();
    List<string> MaterialGroups = new();

    protected override int BuildHash()
    {
        var hc = new HashCode();
        foreach ( var go in Target ?? [] )
        {
            hc.Add( go.Id );
            hc.Add( ResolveMass( go.GetComponent<Rigidbody>() ) );
            hc.Add( go.GetComponent<Prop>()?.Health ?? -1f );
            hc.Add( go.GetComponent<ModelRenderer>()?.MaterialGroup );
        }
        return hc.ToHashCode();
    }

    protected override void OnParametersSet()
    {
        base.OnParametersSet();
        RebuildFromTarget();
    }

    void RebuildFromTarget()
    {
        Properties = new();
        Renderers = new();
        MaterialGroups = new();

        if (Target == null) return;

        foreach (var c in Target.SelectMany(x => x.Components.GetAll()).Distinct().GroupBy(x => x is Collider ? typeof(Collider) : x.GetType()))
        {
            CollectProperties(c.ToArray());
        }
    }

    bool HasEditableProperties(Type type, PropertyDescription[] properties)
    {
        if (type.IsAssignableTo(typeof(ModelRenderer))) return true;
        if (type.IsAssignableTo(typeof(Collider))) return true;

        foreach (var prop in properties)
        {
            if (prop.HasAttribute<ClientEditableAttribute>())
                return true;
        }

        return false;
    }

    void CollectProperties(Component[] components)
    {
        var firstComponent = components.First();

        var tl = TypeLibrary.GetType(firstComponent.GetType());
        if (tl is null) return;

        if (!HasEditableProperties(firstComponent.GetType(), tl.Properties)) return;

        var so = new MultiSerializedObject();
        so.OnPropertyChanged = PropertyChanged;

        foreach (var component in components)
            so.Add(TypeLibrary.GetSerializedObject(component));

        so.Rebuild();

        foreach (var prop in tl.Properties)
        {
            if (!prop.HasAttribute<ClientEditableAttribute>()) continue;
            Properties.Add(so.GetProperty(prop.Name));
        }

        if (firstComponent is ModelRenderer mr)
        {
            Renderers.AddRange(components.OfType<ModelRenderer>());

            var model = mr.Model;
            if ( model is not null )
            {
                for ( int i = 0; i < model.MaterialGroupCount; i++ )
                    MaterialGroups.Add( model.GetMaterialGroupName( i ) );
            }

            var prop = mr.GetComponent<Prop>();
            if (prop is not null)
            {
                var propso = TypeLibrary.GetSerializedObject(prop);
                propso.OnPropertyChanged = PropertyChanged;
                Properties.Add(propso.GetProperty(nameof(ModelRenderer.Tint)));
            }
            else
            {
                Properties.Add(so.GetProperty(nameof(ModelRenderer.Tint)));
            }

            Properties.Add(so.GetProperty(nameof(ModelRenderer.RenderType)));
        }

        if (firstComponent is Collider)
            Properties.Add(so.GetProperty(nameof(Collider.Surface)));
    }

    void PropertyChanged(SerializedProperty prop)
    {
        foreach (var c in prop.Parent.Targets)
        {
            if (c is Component component)
                GameManager.ChangeProperty(component, prop.Name, prop.GetValue<object>());
        }
    }

    void PickMaterialGroup()
    {
        var menu = MenuPanel.Open( this );
        var current = Renderers[0].MaterialGroup ?? MaterialGroups.FirstOrDefault();
        foreach ( var group in MaterialGroups )
        {
            var g = group;
            menu.AddOption( current == g ? "check" : "", g, () => SetMaterialGroup( g ) );
        }
    }

    void SetMaterialGroup( string group )
    {
        foreach ( var renderer in Renderers )
            GameManager.ChangeProperty( renderer, nameof( ModelRenderer.MaterialGroup ), group );
    }

    void PickMaterial(int index)
    {
        var accessor = Renderers[0].Materials;
        var mat = accessor.HasOverride(index) ? accessor.GetOverride(index) : accessor.GetOriginal(index);

        var popup = new ResourceSelectPopup();
        popup.Extension = "material";
        popup.CurrentValue = mat?.ResourcePath;
        popup.AllowPackages = true;
        popup.Parent = FindPopupPanel();
        popup.OnSelectedFile = (path) => SetMaterialOverride(index, path);
    }

    void SetMaterialOverride(int index, string path)
    {
        foreach (var renderer in Renderers)
            GameManager.ChangeMaterialOverride(renderer, index, path);
    }

    void RevertMaterial(int index)
    {
        foreach (var renderer in Renderers)
            GameManager.ChangeMaterialOverride(renderer, index, null);
    }
}