Player/FirstPersonCosmetics.cs
using System;
using System.Threading;
using System.Threading.Tasks;

namespace Causal;

public sealed class FirstPersonCosmetics : Component
{
	[Property] public SkinnedModelRenderer ArmsRenderer { get; set; }
	[Property] public SkinnedModelRenderer ClothingRenderer { get; set; }
	[Property] public ulong ClothingBodyGroups { get; set; } = 1088;
	[Property] public List<string> ExcludedCategories { get; set; } = new() { "Gloves" };
	[Property] public List<string> RemovedClothingNames { get; set; } = new() { "y_front_pants" };
	[Property] public float CosmeticSettleTimeout { get; set; } = 30f;

	public static bool IsLoadingClothing { get; private set; }

	private const ulong BareBodyGroups = 21;
	private const ulong ClothedBodyGroups = 20;
	private int _generation;
	private CancellationTokenSource _cts;

	protected override void OnStart()
	{
		if ( !ArmsRenderer.IsValid() || !ClothingRenderer.IsValid() )
		{
			Log.Warning( "FirstPersonCosmetics has no valid arms/clothing renderer, cosmetics disabled." );
			return;
		}

		_cts = new CancellationTokenSource();
		int generation = ++_generation;
		_ = ApplyCosmeticsAsync( generation, _cts.Token );
	}

	protected override void OnDestroy()
	{
		_generation++;
		if ( _cts is not null )
		{
			_cts.Cancel();
			_cts.Dispose();
			_cts = null;
		}
	}

	private async Task ApplyCosmeticsAsync( int generation, CancellationToken token )
	{
		try
		{
			var container = ClothingContainer.CreateFromLocalUser();
			if ( !IsCurrent( generation, token ) )
			{
				return;
			}

			StripExcludedCategories( container );

			IsLoadingClothing = true;

			await container.ApplyAsync( ClothingRenderer, token );
			if ( !IsCurrent( generation, token ) )
			{
				return;
			}

			await WaitForSettled( container, generation, token );
			if ( !IsCurrent( generation, token ) )
			{
				return;
			}

			SetLoadingForGeneration( generation, false );

			if ( StripExcludedCategories( container ) > 0 )
			{
				container.Apply( ClothingRenderer );
			}

			RemoveNamedClothingObjects();
			ApplyClothingRenderOptions();
			ApplyClothingBodyGroups();
			SetArmsBodyGroups( CountEntries( container ) > 0 ? ClothedBodyGroups : BareBodyGroups );
		}
		catch ( OperationCanceledException )
		{
			SetLoadingForGeneration( generation, false );
			SetArmsBodyGroupsForGeneration( generation, BareBodyGroups );
		}
		catch ( Exception exception )
		{
			SetLoadingForGeneration( generation, false );
			SetArmsBodyGroupsForGeneration( generation, BareBodyGroups );
			Log.Warning( $"FirstPersonCosmetics failed: {exception.Message}" );
		}
	}

	private bool IsCurrent( int generation, CancellationToken token )
	{
		return generation == _generation && !token.IsCancellationRequested;
	}

	private void SetArmsBodyGroupsForGeneration( int generation, ulong groups )
	{
		if ( generation == _generation )
		{
			SetArmsBodyGroups( groups );
		}
	}

	private void SetArmsBodyGroups( ulong groups )
	{
		if ( ArmsRenderer.IsValid() )
		{
			ArmsRenderer.BodyGroups = groups;
		}
	}

	private void ApplyClothingBodyGroups()
	{
		if ( ClothingRenderer.IsValid() )
		{
			ClothingRenderer.BodyGroups = ClothingBodyGroups;
		}
	}

	private void SetLoadingForGeneration( int generation, bool loading )
	{
		if ( generation == _generation )
		{
			IsLoadingClothing = loading;
		}
	}

	private void ApplyClothingRenderOptions()
	{
		if ( !ClothingRenderer.IsValid() )
		{
			return;
		}

		ApplyClothingRenderOptions( ClothingRenderer.GameObject );
	}

	private static void ApplyClothingRenderOptions( GameObject root )
	{
		if ( !root.IsValid() || root.IsDestroyed )
		{
			return;
		}

		var renderer = root.GetComponent<SkinnedModelRenderer>();
		if ( renderer.IsValid() )
		{
			renderer.RenderType = ModelRenderer.ShadowRenderType.Off;
			var options = renderer.RenderOptions;
			options.Game = false;
			options.Overlay = true;
			options.Bloom = false;
			options.AfterUI = false;
		}

		foreach ( GameObject child in root.Children )
		{
			ApplyClothingRenderOptions( child );
		}
	}

	private void RemoveNamedClothingObjects()
	{
		if ( RemovedClothingNames is null || RemovedClothingNames.Count == 0 )
		{
			return;
		}

		if ( !ClothingRenderer.IsValid() )
		{
			return;
		}

		int removed = 0;
		RemoveNamedClothingObjects( ClothingRenderer.GameObject, ref removed );
		if ( removed > 0 )
		{
			Log.Info( $"FirstPersonCosmetics removed {removed} unwanted clothing objects." );
		}
	}

	private void RemoveNamedClothingObjects( GameObject root, ref int removed )
	{
		if ( !root.IsValid() || root.IsDestroyed )
		{
			return;
		}

		foreach ( GameObject child in root.Children )
		{
			if ( !child.IsValid() || child.IsDestroyed )
			{
				continue;
			}

			if ( IsRemovedClothingName( child.Name ) )
			{
				child.Destroy();
				removed++;
				continue;
			}

			RemoveNamedClothingObjects( child, ref removed );
		}
	}

	private bool IsRemovedClothingName( string name )
	{
		if ( string.IsNullOrEmpty( name ) || RemovedClothingNames is null )
		{
			return false;
		}

		foreach ( string removed in RemovedClothingNames )
		{
			if ( !string.IsNullOrEmpty( removed ) && name.IndexOf( removed, StringComparison.OrdinalIgnoreCase ) >= 0 )
			{
				return true;
			}
		}

		return false;
	}

	private static int CountEntries( ClothingContainer container )
	{
		if ( container?.Clothing is null )
		{
			return 0;
		}

		int count = 0;
		foreach ( var entry in container.Clothing )
		{
			if ( entry is not null )
			{
				count++;
			}
		}

		return count;
	}

	private int StripExcludedCategories( ClothingContainer container )
	{
		if ( container?.Clothing is null || ExcludedCategories is null || ExcludedCategories.Count == 0 )
		{
			return 0;
		}

		int stripped = 0;
		var seen = new List<string>();
		var entries = new List<ClothingContainer.ClothingEntry>( container.Clothing );
		foreach ( var entry in entries )
		{
			string category = entry?.Clothing?.Category.ToString();
			if ( string.IsNullOrEmpty( category ) )
			{
				continue;
			}

			if ( !seen.Contains( category ) )
			{
				seen.Add( category );
			}

			foreach ( string excluded in ExcludedCategories )
			{
				if ( string.Equals( category, excluded, StringComparison.OrdinalIgnoreCase ) )
				{
					container.Clothing.Remove( entry );
					stripped++;
					break;
				}
			}
		}

		if ( stripped > 0 )
		{
			Log.Info( $"FirstPersonCosmetics excluded {stripped} clothing entries." );
		}
		else if ( entries.Count > 0 )
		{
			Log.Info( $"FirstPersonCosmetics excluded nothing, categories present: {string.Join( ", ", seen )}" );
		}

		return stripped;
	}

	private async Task WaitForSettled( ClothingContainer container, int generation, CancellationToken token )
	{
		TimeSince elapsed = 0;
		while ( HasPendingEntries( container ) )
		{
			if ( elapsed > CosmeticSettleTimeout )
			{
				Log.Warning( $"FirstPersonCosmetics timed out after {CosmeticSettleTimeout:0}s waiting for downloads, applying what is loaded." );
				return;
			}

			await GameTask.DelaySeconds( 0.5f );
			if ( !IsCurrent( generation, token ) )
			{
				return;
			}
		}
	}

	private static bool HasPendingEntries( ClothingContainer container )
	{
		if ( container?.Clothing is null )
		{
			return false;
		}

		foreach ( var entry in container.Clothing )
		{
			if ( entry is not null && entry.Clothing is null && entry.ItemDefinitionId != 0 )
			{
				return true;
			}
		}

		return false;
	}
}