DresserPlus.cs
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;

namespace Sandbox;

/// <summary>
/// Dresses a Citizen or Human using one of four clothing sources (Manual, LocalUser, OwnerUser, Hybrid).
/// Body attributes (height, age, tint) are synced across the network in real time.
/// </summary>
[Title( "Dresser Plus" )]
[Category( "Game" )]
[Icon( "checkroom" )]
public sealed class DresserPlus : Component, Component.ExecuteInEditor
{
	public enum ClothingSource
	{
		/// <summary>
		/// Dresses using manually selected clothing.
		/// </summary>
		[Icon( "format_list_bulleted" )]
		Manual,

		/// <summary>
		/// Dresses using the local client's avatar.
		/// Each client sees their own clothing.
		/// </summary>
		[Icon( "sync_disabled" )]
		LocalUser,

		/// <summary>
		/// Dresses using the avatar of this GameObject's network owner.
		/// All clients see that same clothing.
		/// </summary>
		[Icon( "sync" )]
		OwnerUser,

		/// <summary>
		/// Dresses using the avatar of this GameObject's network owner with selected categories stripped and manual clothing applied on top.
		/// All clients see that same clothing.
		/// </summary>
		[Icon( "cloud_sync" )]
		Hybrid
	}

	/// <summary>
	/// Who we are dressing.
	/// This should be the renderer of the body of a Citizen or Human.
	/// </summary>
	[Property]
	public SkinnedModelRenderer BodyTarget { get; set; }

	/// <summary>
	/// Where the clothing should be sourced from.
	/// </summary>
	[Property]
	public ClothingSource Source { get; set; }
	
	/// <summary>
	/// Strips any clothing items that are not owned in the client's Steam Inventory.
	/// Disable only if your game handles ownership checks itself.
	/// </summary>
	[Property, Group( "Parameters" )]
	[ShowIf( nameof(ShowOwnerOptions), true )]
	public bool RemoveUnownedItems { get; set; } = true;

	/// <summary>
	/// Whether the height should be scaled or a standard value for all bodies be used.
	/// When false, changing the height scale won't have any effect.
	/// </summary>
	[Property, Group( "Parameters" )]
	public bool ApplyHeightScale { get; set; } = true;

	/// <summary>
	/// Controls the body's height scale.
	/// </summary>
	[Property, Group( "Parameters" ), Range( 0.5f, 1.5f )]
	[ShowIf( nameof(ShowHeightOption), true )]
	[Change( nameof(OnManualChange) )]
	[Sync]
	public float ManualHeight { get; set; } = 1.0f;

	/// <summary>
	/// Controls the body's intensity of skin aging.
	/// 0 is smooth/young, 1 is wrinkled/aged.
	/// </summary>
	[Property, Group( "Parameters" ), Range( 0, 1 )]
	[ShowIf( nameof(ShowManualOptions), true )]
	[Change( nameof(OnManualChange) )]
	[Sync]
	public float ManualAge { get; set; } = 0.5f;

	/// <summary>
	/// Blends the body's skin color along the skin color spectrum.
	/// </summary>
	[Property, Group( "Parameters" ), Range( 0, 1 )]
	[ShowIf( nameof(ShowManualOptions), true )]
	[Change( nameof(OnManualChange) )]
	[Sync]
	public float ManualTint { get; set; } = 0.5f;

	/// <summary>
	/// Clothing items to dress the body with.
	/// </summary>
	[Property, Group( "Parameters" )]
	[ShowIf( nameof(ShowManualOptions), true )]
	public List<ClothingContainer.ClothingEntry> Clothing { get; set; }

	/// <summary>
	/// Workshop clothing package identifiers to fetch and dress the body with.
	/// </summary>
	[Property, Group( "Parameters" )]
	[ShowIf( nameof(ShowManualOptions), true )]
	public List<string> WorkshopClothing { get; set; }

	/// <summary>
	/// Clothing categories to strip from the owner's avatar before applying manual clothing.
	/// </summary>
	[Property, Group( "Parameters" )]
	[ShowIf( nameof( Source ), ClothingSource.Hybrid )]
	public List<Clothing.ClothingCategory> StrippedCategories { get; set; }

	/// <summary>
	/// True while <see cref="Apply()"/> is running asynchronously.
	/// </summary>
	public bool IsDressing { get; private set; }

	private bool ShowManualOptions => Source is ClothingSource.Manual or ClothingSource.Hybrid;
	private bool ShowOwnerOptions => Source is ClothingSource.OwnerUser or ClothingSource.Hybrid;
	private bool ShowHeightOption => ShowManualOptions && ApplyHeightScale;
	private bool NeedsNetworkOwner => Source is ClothingSource.OwnerUser or ClothingSource.Hybrid;

	private CancellationTokenSource _cts;

	protected override void OnAwake()
	{
		if ( IsProxy ) return;

		if ( !BodyTarget.IsValid() )
			BodyTarget = GetComponentInChildren<SkinnedModelRenderer>();

		_ = ApplyWhenReady();
	}

	protected override void OnEnabled()
	{
		if ( IsProxy )
			ApplyAttributes();
	}

	protected override void OnValidate()
	{
		if ( IsProxy ) return;

		base.OnValidate();

		using var p = Scene.Push();

		if ( !BodyTarget.IsValid() )
			BodyTarget = GetComponentInChildren<SkinnedModelRenderer>();

		_ = Apply();
	}

	protected override void OnDestroy()
	{
		CancelDressing();
	}

	/// <summary>
	/// Fetches a workshop clothing package by identifier, mounts it, and returns the clothing resource.
	/// </summary>
	/// <param name="ident">The workshop package identifier.</param>
	/// <param name="token">The async operation cancellation token.</param>
	/// <returns>The clothing resource, or null if the package was not found or invalid.</returns>
	private static async Task<Clothing> InstallWorkshopClothing( string ident, CancellationToken token )
	{
		if ( string.IsNullOrEmpty( ident ) ) return null;

		var package = await Package.FetchAsync( ident, false );
		if ( package is null )
		{
			Log.Warning( $"DresserPlus: Failed to fetch workshop package '{ident}'" );
			return null;
		}
		if ( package.TypeName != "clothing" ) return null;
		if ( token.IsCancellationRequested ) return null;

		var primaryAsset = package.PrimaryAsset;
		if ( string.IsNullOrWhiteSpace( primaryAsset ) ) return null;

		var fs = await package.MountAsync();
		if ( fs is null )
		{
			Log.Warning( $"DresserPlus: Failed to mount workshop package '{ident}'" );
			return null;
		}
		
		return token.IsCancellationRequested ? null : ResourceLibrary.Get<Clothing>( primaryAsset );
	}

	/// <summary>
	/// Builds a <see cref="ClothingContainer"/> based on the selected <see cref="Source"/>.
	/// </summary>
	/// <param name="token">The async operation cancellation token.</param>
	/// <returns>The built clothing container, or null if the source is not recognized.</returns>
	private async ValueTask<ClothingContainer> GetClothing( CancellationToken token )
	{
		switch ( Source )
		{
			case ClothingSource.Manual:
				{
					var clothing = new ClothingContainer();
					await AddManualClothing( clothing, token );
					clothing.Normalize();
					return clothing;
				}
			case ClothingSource.LocalUser:
				return ClothingContainer.CreateFromLocalUser();
			case ClothingSource.OwnerUser when Network.Owner != null:
				return ClothingContainer.CreateFromConnection( Network.Owner, RemoveUnownedItems );
			case ClothingSource.OwnerUser:
				return new ClothingContainer();
			case ClothingSource.Hybrid:
				{
					var clothing = Network.Owner != null ? ClothingContainer.CreateFromConnection( Network.Owner, RemoveUnownedItems ) : new ClothingContainer();

					if ( StrippedCategories is { Count: > 0 } )
						clothing.Clothing.RemoveAll( e => StrippedCategories.Contains( e.Clothing.Category ) );

					await AddManualClothing( clothing, token );
					clothing.Normalize();
					return clothing;
				}
			default:
				return null;
		}
	}

	/// <summary>
	/// Adds <see cref="Clothing"/>, <see cref="WorkshopClothing"/>, and body attributes
	/// (<see cref="ManualHeight"/>, <see cref="ManualAge"/>, <see cref="ManualTint"/>) to the given <see cref="ClothingContainer"/>.
	/// </summary>
	/// <param name="clothing">The container to add items and attributes to.</param>
	/// <param name="token">The async operation cancellation token.</param>
	private async ValueTask AddManualClothing( ClothingContainer clothing, CancellationToken token )
	{
		clothing.AddRange( Clothing );
		clothing.Height = ManualHeight.Remap( 0.5f, 1.5f, 0, 1, true );
		clothing.Age = ManualAge;
		clothing.Tint = ManualTint;

		if ( WorkshopClothing is not { Count: > 0 } ) return;

		var tasks = WorkshopClothing.Select( s => InstallWorkshopClothing( s, token ) ).ToArray();
		await Task.WhenAll( tasks );

		foreach ( var task in tasks )
		{
			if ( task.Result is not null )
				clothing.Add( task.Result );
		}
	}

	/// <summary>
	/// Called when <see cref="ManualHeight"/>, <see cref="ManualAge"/>, or <see cref="ManualTint"/> is changed.
	/// </summary>
	private void OnManualChange()
	{
		ApplyAttributes();
	}
	
	/// <summary>
	/// Waits for the network owner to be assigned (if required by the current <see cref="Source"/>), then applies clothing.
	/// </summary>
	private async ValueTask ApplyWhenReady()
	{
		if ( NeedsNetworkOwner )
		{
			while ( Network.Owner is null )
			{
				if ( !this.IsValid() ) return;
				await Task.Frame();
			}
		}

		await Apply();
	}

	/// <summary>
	/// Applies <see cref="ManualHeight"/>, <see cref="ManualAge"/>, and <see cref="ManualTint"/> on the <see cref="BodyTarget"/>.
	/// </summary>
	private void ApplyAttributes()
	{
		if ( BodyTarget is null ) return;

		if ( ApplyHeightScale )
			BodyTarget.Set( "scale_height", ManualHeight );
		else
			BodyTarget.Set( "scale_height", 1 );

		foreach ( var r in BodyTarget.GetComponentsInChildren<SkinnedModelRenderer>() )
		{
			r.Attributes.Set( "skin_age", ManualAge );
			r.Attributes.Set( "skin_tint", ManualTint );
		}
	}

	/// <summary>
	/// Button wrapper for <see cref="Apply()"/>.
	/// </summary>
	[Button( "Apply Changes" )]
	private void ApplyButton() => _ = Apply();

	/// <summary>
	/// Dresses the <see cref="BodyTarget"/> using the selected <see cref="Source"/>.
	/// Cancels any in-progress dressing, fetches the clothing, and applies it asynchronously.
	/// </summary>
	public async ValueTask Apply()
	{
		CancelDressing();

		if ( !BodyTarget.IsValid() ) return;

		_cts = new CancellationTokenSource();
		var token = _cts.Token;

		IsDressing = true;

		try
		{
			var clothing = await GetClothing( token );
			if ( clothing is null || token.IsCancellationRequested ) return;
			if ( !BodyTarget.IsValid() ) return;

			if ( !ApplyHeightScale )
				clothing.Height = 1;

			clothing.Normalize();

			await clothing.ApplyAsync( BodyTarget, token );

			ManualHeight = clothing.Height.Remap( 0, 1, 0.5f, 1.5f, true );
			ManualTint = clothing.Tint;
			ManualAge = clothing.Age;

			ApplyAttributes();
		}
		finally
		{
			IsDressing = false;
		}
	}

	/// <summary>
	/// Sets <see cref="Clothing"/> to the given items and applies them to the <see cref="BodyTarget"/>.
	/// </summary>
	/// <param name="clothing">The clothing items to apply.</param>
	public ValueTask Apply( List<ClothingContainer.ClothingEntry> clothing )
	{
		Clothing = clothing;
		return Apply();
	}

	/// <summary>
	/// Removes all clothing from the <see cref="BodyTarget"/>, stripping it back to the bare model.
	/// </summary>
	[Button( "Clear Changes" )]
	public void Clear()
	{
		CancelDressing();

		if ( !BodyTarget.IsValid() ) return;

		var clothing = new ClothingContainer();
		clothing.Apply( BodyTarget );

		ManualHeight = 1.0f;
		ManualAge = 0.5f;
		ManualTint = 0.5f;
		ApplyAttributes();
	}
	
	/// <summary>
	/// Cancels any in-progress async dressing and disposes the cancellation token.
	/// </summary>
	public void CancelDressing()
	{
		_cts?.Cancel();
		_cts?.Dispose();
		_cts = null;
	}
}