UI/Effects/EffectsList.razor
@using Sandbox;
@using Sandbox.UI;
@inherits Panel
@namespace Sandbox

<root>
    <div class="list-tabs">
        <div class="list-tab @( _tab == 0 ? "active" : "" )" @onclick="@(() => SwitchTab(0))"><label>📦 </label><label>#spawnmenu.effects.installed</label></div>
        <div class="list-tab @( _tab == 1 ? "active" : "" )" @onclick="@(() => SwitchTab(1))"><label>☁️ </label><label>#spawnmenu.effects.workshop</label></div>
    </div>

    <div class="panel-body">
        <div class="scroll-body">
            @if ( _tab == 0 )
            {
            @foreach ( var group in _groups )
            {
                <div class="group-header">@group.Key.ToString()</div>

                @foreach ( var entry in group )
                {
                    var path = entry.ResourcePath;
                    var enabled = _manager?.IsEnabled( path ) ?? false;
                    var selected = _manager?.SelectedPath == path;

                    <div class="effect-row @( selected ? "selected" : "" )"
                         @onclick="@(() => OnClick( entry ))"
                         @onmouseenter="@(() => _manager?.Preview( path ))"
                         @onmouseleave="@(() => _manager?.Unpreview())">

                        <div class="thumb emoji">@GroupEmoji( entry.Group )</div>
                        <div class="name">@entry.Title</div>
                        <div class="toggle @( enabled ? "on" : "" )" @onclick:stopPropagation="true" @onclick="@(() => OnToggle( entry ))">
                            <div class="checkmark">✓</div>
                        </div>

                    </div>
                }
            }

            @if ( !_groups.Any() )
            {
                <div class="empty-state">#spawnmenu.effects.no_installed</div>
            }
        }
        else
        {
            <div class="workshop-search">
                <TextEntry class="search-input" placeholder="#spawnmenu.common.search" value="@_filter"/>
            </div>

            @if ( _loadingWorkshop )
            {
                <div class="empty-state">#spawnmenu.common.loading</div>
            }
            else if ( _workshopPackages.Count == 0 )
            {
                <div class="empty-state">#spawnmenu.common.no_results</div>
            }
            else
            {
                @foreach ( var pkg in _workshopPackages )
                {
                    var mounted = _mountedResources.GetValueOrDefault( pkg.FullIdent );
                    var resourcePath = mounted?.ResourcePath;
                    var enabled = resourcePath != null && (_manager?.IsEnabled( resourcePath ) ?? false);
                    var selected = resourcePath != null && _manager?.SelectedPath == resourcePath;
                    var mounting = _mounting.Contains( pkg.FullIdent );
                    var thumbUrl = mounted?.Icon != null ? $"thumb:{mounted.Icon.ResourcePath}" : pkg.Thumb;

                    <div class="effect-row @( selected ? "selected" : "" )"
                         @onclick="@(() => OnWorkshopClick( pkg ))"
                         @onmouseenter="@(() => { if ( resourcePath != null ) _manager?.Preview( resourcePath ); })"
                         @onmouseleave="@(() => _manager?.Unpreview())">

                        <div class="thumb" style="background-image: url(@thumbUrl)"></div>
                        <div class="info">
                            <div class="name">@pkg.Title</div>
                            <div class="author">
                                <div class="author-avatar" style="background-image: url(@pkg.Org.Thumb)"></div>
                                <span>@pkg.Org.Title</span>
                            </div>
                        </div>
                        <div class="toggle @( enabled ? "on" : "" ) @( mounting ? "loading" : "" )"
                             @onclick:stopPropagation="true" @onclick="@(() => OnWorkshopToggle( pkg ))">
                            <div class="checkmark">@( mounting ? "…" : "✓" )</div>
                        </div>

                    </div>
                }
            }
        }
    </div>

    @if ( _tab == 0 )
    {
        <div class="footer">
            <Button class="menu-action primary" Text="#spawnmenu.effects.presets" Icon="📋" onclick=@OpenPresetsMenu></Button>
        </div>
    }
    </div>
</root>

@code
{
    PostProcessManager _manager;
    IGrouping<PostProcessGroup, PostProcessResource>[] _groups = [];
    int _tab = 0;

    // Workshop
    string _filter = "";
    bool _loadingWorkshop;
    List<Package> _workshopPackages = new();
    Dictionary<string, PostProcessResource> _mountedResources = new();
    HashSet<string> _mounting = new();

    protected override void OnAfterTreeRender( bool firstTime )
    {
        if ( !firstTime ) return;

        _manager = Game.ActiveScene.GetSystem<PostProcessManager>();
        RefreshInstalled();
        StateHasChanged();
    }

    void RefreshInstalled()
    {
        _groups = ResourceLibrary.GetAll<PostProcessResource>()
            .OrderBy( r => r.Group )
            .ThenBy( r => r.Title )
            .GroupBy( r => r.Group )
            .ToArray();
    }

    void SwitchTab( int tab )
    {
        _tab = tab;
        if ( tab == 1 && _workshopPackages.Count == 0 )
            _ = FetchWorkshop();
        StateHasChanged();
    }

    async Task FetchWorkshop()
    {
        _loadingWorkshop = true;
        StateHasChanged();

        var query = string.IsNullOrEmpty(_filter) ? "sort:newest" : $"sort:newest {_filter}";
        var result = await Package.FindAsync( query );
        _workshopPackages = result?.Packages.Where( p => p.TypeName == "spp" ).ToList() ?? new();

        _loadingWorkshop = false;
        StateHasChanged();
    }

    void OnFilterInput()
    {
        _ = FetchWorkshop();
    }

    async Task<PostProcessResource> MountAndGet( Package pkg )
    {
        if ( _mountedResources.TryGetValue( pkg.FullIdent, out var cached ) ) return cached;

        _mounting.Add( pkg.FullIdent );
        StateHasChanged();

        var resource = await ResourceLibrary.LoadAsync<PostProcessResource>( pkg.FullIdent );
        resource ??= await Cloud.Load<PostProcessResource>( pkg.FullIdent, true );

        _mounting.Remove( pkg.FullIdent );

        if ( resource != null )
            _mountedResources[pkg.FullIdent] = resource;

        StateHasChanged();
        return resource;
    }

    async void OnWorkshopClick( Package pkg )
    {
        var resource = await MountAndGet( pkg );
        if ( resource is null ) return;
        _manager?.Select( resource.ResourcePath );
        StateHasChanged();
    }

    async void OnWorkshopToggle( Package pkg )
    {
        var resource = await MountAndGet( pkg );
        if ( resource is null ) return;
        _manager?.Toggle( resource.ResourcePath );
        StateHasChanged();
    }

    void OnClick( PostProcessResource entry )
    {
        _manager?.Select( entry.ResourcePath );
        StateHasChanged();
    }

    void OnToggle( PostProcessResource entry )
    {
        _manager?.Toggle( entry.ResourcePath );
        StateHasChanged();
    }

    static string GroupEmoji( PostProcessGroup group ) => group switch
    {
        PostProcessGroup.Effects  => "✨",
        PostProcessGroup.Overlay  => "🎭",
        PostProcessGroup.Shaders  => "⚡",
        PostProcessGroup.Textures => "🧱",
        _                         => "🔧",
    };

    const string PresetsKey = "effects/presets";

    record EffectState( string ResourcePath, Dictionary<string, object> Properties );
    record EffectsPreset( string Name, List<EffectState> Effects );
    record EffectsPresetList( List<EffectsPreset> Presets );

    EffectsPresetList LoadPresetList() => LocalData.Get<EffectsPresetList>( PresetsKey, new EffectsPresetList( new() ) );

    Dictionary<string, object> CaptureProperties( IReadOnlyList<Component> components )
    {
        var result = new Dictionary<string, object>();
        foreach ( var component in components )
        {
            var so = TypeLibrary.GetSerializedObject( component );
            foreach ( var prop in so.Where( p => !p.IsMethod
                && p.PropertyType != null
                && !p.PropertyType.IsAssignableTo( typeof( Delegate ) )
                && p.HasAttribute<PropertyAttribute>() ) )
            {
                try
                {
                    var value = prop.GetValue<object>();
                    if ( value is not null )
                        result[prop.Name] = value;
                }
                catch { }
            }
        }
        return result;
    }

    void ApplyProperties( string resourcePath, Dictionary<string, object> properties )
    {
        foreach ( var component in _manager.GetComponents( resourcePath ) )
        {
            var typeDesc = TypeLibrary.GetType( component.GetType() );
            foreach ( var (name, value) in properties )
            {
                var propDesc = typeDesc?.GetProperty( name );
                if ( propDesc is null ) continue;

                try
                {
                    var resolved = value is System.Text.Json.JsonElement el
                        ? Sandbox.Json.Deserialize( el.GetRawText(), propDesc.PropertyType )
                        : value;
                    if ( resolved is not null )
                        propDesc.SetValue( component, resolved );
                }
                catch { }
            }
        }
    }

    void SaveNewPreset( string name )
    {
        if ( _manager is null ) return;

        var effects = _groups.SelectMany( g => g )
            .Where( r => _manager.IsEnabled( r.ResourcePath ) )
            .Select( r => new EffectState( r.ResourcePath, CaptureProperties( _manager.GetComponents( r.ResourcePath ) ) ) )
            .ToList();

        var list = LoadPresetList();
        list.Presets.RemoveAll( p => p.Name == name );
        list.Presets.Add( new EffectsPreset( name, effects ) );
        LocalData.Set( PresetsKey, list );
        StateHasChanged();
    }

    void ApplyPreset( EffectsPreset preset )
    {
        if ( _manager is null ) return;

        var presetPaths = preset.Effects.Select( e => e.ResourcePath ).ToHashSet();

        foreach ( var group in _groups )
            foreach ( var entry in group )
                if ( !presetPaths.Contains( entry.ResourcePath ) && _manager.IsEnabled( entry.ResourcePath ) )
                    _manager.Toggle( entry.ResourcePath );

        foreach ( var effectState in preset.Effects )
        {
            if ( !_manager.IsEnabled( effectState.ResourcePath ) )
                _manager.Toggle( effectState.ResourcePath );

            ApplyProperties( effectState.ResourcePath, effectState.Properties );
        }

        StateHasChanged();
    }

    void DeletePreset( string name )
    {
        var list = LoadPresetList();
        list.Presets.RemoveAll( p => p.Name == name );
        if ( list.Presets.Count == 0 )
            LocalData.Delete( PresetsKey );
        else
            LocalData.Set( PresetsKey, list );
        StateHasChanged();
    }

    void OpenPresetsMenu()
    {
        var menu = MenuPanel.Open( this );

        menu.AddOption( "save", Game.Language.GetPhrase( "spawnmenu.effects.save_new_preset" ), () =>
        {
            var popup = new StringQueryPopup
            {
                Title = "#spawnmenu.effects.save_preset",
                Prompt = "#spawnmenu.effects.save_preset_prompt",
                Placeholder = "#spawnmenu.effects.preset_name_placeholder",
                ConfirmLabel = "#spawnmenu.common.save",
                OnConfirm = name => SaveNewPreset( name )
            };
            popup.Parent = FindPopupPanel();
        } );

        var list = LoadPresetList();
        if ( list.Presets.Count > 0 )
        {
            menu.AddSpacer();
            foreach ( var preset in list.Presets )
            {
                var captured = preset;
                menu.AddSubmenu( "auto_awesome", captured.Name, sub =>
                {
                    sub.AddOption( "play_arrow", Game.Language.GetPhrase( "spawnmenu.common.load" ), () => ApplyPreset( captured ) );
                    sub.AddOption( "delete", Game.Language.GetPhrase( "spawnmenu.inspect.delete" ), () => DeletePreset( captured.Name ) );
                } );
            }
        }
    }

    protected override int BuildHash() =>
        HashCode.Combine( _tab, _manager?.SelectedPath, _manager?.IsEnabled( _manager?.SelectedPath ?? "" ), _loadingWorkshop );
}