DiamondMusic.cs
using System;
using Sandbox.Audio;

namespace Diamonds;

/// <summary>
/// Decodes the inspector's music clips, picks a motif per run, maps run progress onto tiers and streams
/// the clip mixer through the Music mixer.
/// </summary>
public sealed class DiamondMusic
{
	const float BufferSeconds = 0.12f;
	readonly DiamondMusicMixer mixer = new();
	readonly Random random = new();
	readonly Dictionary<SoundFile, (short[] Samples, int Channels)> decoded = new();
	readonly HashSet<SoundFile> loading = new();
	readonly List<string> motifs = new();
	SoundStream stream;
	SoundHandle handle;
	float[] mix = new float[8192];
	short[] pcm = new short[8192];
	int resetVersion = -1;
	bool menuRun;
	// A run was queued for a screen switch still fading out; its reset must not queue another.
	bool anticipated;
	// Times the current wait for clips to decode from when it begins (decoding starts from Update), not from
	// construction during scene load, whose own loading and warm-up would otherwise use up the allowance.
	bool waitingForClips;
	RealTimeSince sinceWaitStarted;
	RealTimeSince sinceStartFailed = 10;

	public IReadOnlyList<DiamondMusicClip> Clips { get; set; } = Array.Empty<DiamondMusicClip>();
	/// <summary>Pieces placed to request tiers 1, 2 and 3. The mixer still climbs one tier per phrase.</summary>
	public (int Tier1, int Tier2, int Tier3) TierPieces { get; set; } = (4, 12, 28);
	public float Volume { get; set; } = 0.5f;
	/// <summary>Forces a tier for auditioning; negative follows pieces placed.</summary>
	public int TierOverride { get; set; } = -1;
	/// <summary>The fixed tier the menu plays at, with a fresh random motif and clips on every visit.</summary>
	public int MenuTier { get; set; } = 2;
	public float SwapChance
	{
		get => mixer.SwapChance;
		set => mixer.SwapChance = value;
	}
	public float SwapFalloff
	{
		get => mixer.SwapFalloff;
		set => mixer.SwapFalloff = value;
	}
	public int Tier => mixer.Tier;
	/// <summary>False for an instance hotloaded from an older shape, whose new fields were never initialized.</summary>
	public bool IsIntact => mixer is not null && mixer.IsIntact && decoded is not null && loading is not null && motifs is not null && mix is not null;
	public string Motif => mixer.Motif;

	/// <summary>
	/// Maps the board onto a tier, then tops up the stream. Outside a game (<paramref name="inGame"/> false,
	/// the menu) every visit plays a fresh random mix held at <see cref="MenuTier"/>; <paramref name="suspended"/>
	/// (paused, tutorial or escape menu) muffles it without stopping the clock, and <paramref name="ducked"/>
	/// (the escape menu) also lowers its volume. While <paramref name="switching"/>, <paramref name="inGame"/>
	/// names the screen being faded to, whose music starts at once rather than after the fade-out.
	/// </summary>
	public void Update( DiamondBoard board, bool inGame, bool muted, bool suspended, bool ducked, bool switching = false )
	{
#if STANDALONE
#endif
		// Wait for every clip to decode, so held runs (the menu) never start with silent layers; a clip that
		// never loads stops holding music back after a few seconds.
		bool synced = SyncTracks();
		if ( synced ) waitingForClips = false;
		else if ( !waitingForClips )
		{
			waitingForClips = true;
			sinceWaitStarted = 0;
		}
		bool ready = synced || sinceWaitStarted > 5;
		bool menu = !inGame;
		if ( ready && switching )
		{
			if ( !anticipated )
			{
				anticipated = true;
				menuRun = menu;
				mixer.QueueRun( PickMotif(), menu ? Math.Clamp( MenuTier, 0, DiamondMusicMixer.MaxTier ) : -1 );
			}
			resetVersion = board.ResetVersion;
		}
		else if ( ready )
		{
			// The switch's own board reset belongs to the run already queued (unless it was cancelled).
			if ( anticipated && menu == menuRun ) resetVersion = board.ResetVersion;
			anticipated = false;
		}
		if ( ready && !switching && (resetVersion != board.ResetVersion || menu != menuRun) )
		{
			// Every run and every menu visit gets a fresh motif and clips, starting at once (a screen switch).
			resetVersion = board.ResetVersion;
			menuRun = menu;
			mixer.QueueRun( PickMotif(), menu ? Math.Clamp( MenuTier, 0, DiamondMusicMixer.MaxTier ) : -1 );
		}
		var (tier1, tier2, tier3) = TierPieces;
		// The menu and game over keep the clips that are playing; the next run picks fresh ones.
		bool over = !menu && board.GameOver && !switching;
		mixer.Hold = menu || over;
		// A game being faded to starts from tier 0, not the outgoing board's progress.
		int tier = switching ? 0 : board.Placed >= tier3 ? 3 : board.Placed >= tier2 ? 2 : board.Placed >= tier1 ? 1 : 0;
		mixer.RequestedTier = menu ? Math.Clamp( MenuTier, 0, DiamondMusicMixer.MaxTier ) : TierOverride >= 0 ? TierOverride : tier;
		mixer.MasterTarget = muted ? 0 : Volume;
		// Pauses and game over only muffle, keeping the music at full volume.
		mixer.DuckTarget = ducked ? 0.75f : suspended || over ? 1 : !menu && board.IsSliding ? 0.85f : 1;
		mixer.MuffleTarget = suspended || over ? 1 : 0;
		Pump();
	}

	/// <summary>A random motif from the clips' tags, avoiding the previous run's when there is a choice.</summary>
	string PickMotif()
	{
		motifs.Clear();
		foreach ( var clip in Clips ?? Array.Empty<DiamondMusicClip>() )
		{
			if ( clip.Motifs is null ) continue;
			foreach ( var tag in clip.Motifs )
			{
				var motif = tag?.Trim();
				if ( !string.IsNullOrEmpty( motif ) && !motifs.Contains( motif, StringComparer.OrdinalIgnoreCase ) ) motifs.Add( motif );
			}
		}
		if ( motifs.Count > 1 ) motifs.RemoveAll( m => string.Equals( m, mixer.Motif, StringComparison.OrdinalIgnoreCase ) );
		return motifs.Count == 0 ? "" : motifs[random.Next( motifs.Count )];
	}

	/// <summary>Mirror the inspector list into mixer tracks. Returns false while any assigned clip is still loading.</summary>
	bool SyncTracks()
	{
		var tracks = mixer.Tracks;
		int count = 0;
		bool ready = true;
		foreach ( var clip in Clips ?? Array.Empty<DiamondMusicClip>() )
		{
			var sound = clip.Sound;
			if ( sound is null ) continue;
			if ( !sound.IsValid ) { ready = false; continue; }
			if ( !decoded.TryGetValue( sound, out var audio ) ) { Load( sound ); ready = false; continue; }
			var samples = audio.Samples;
			if ( samples is null ) continue;
			if ( count < tracks.Count && tracks[count].Samples == samples ) tracks[count].Clip = clip;
			else if ( count < tracks.Count ) tracks[count] = new( clip, samples, audio.Channels );
			else tracks.Add( new( clip, samples, audio.Channels ) );
			count++;
		}
		tracks.RemoveRange( count, tracks.Count - count );
		return ready;
	}

	async void Load( SoundFile sound )
	{
		if ( !loading.Add( sound ) ) return;
		short[] result = null;
		int channels = 1;
		try
		{
			var samples = await sound.GetSamplesInterleavedAsync();
			channels = sound.Channels == 2 ? 2 : 1;
			if ( samples is { Length: > 0 } ) result = Resample( samples, channels, sound.Rate );
			else Log.Warning( $"Music clip '{sound.ResourcePath}' could not be decoded" );
		}
		catch ( Exception e ) { Log.Warning( e, $"Music clip '{sound.ResourcePath}' failed to decode" ); }
		decoded[sound] = (result, channels);
		loading.Remove( sound );
		if ( result is null ) return;
		float bars = result.Length / (float)channels / DiamondMusicMixer.BarFrames;
		if ( MathF.Abs( bars - MathF.Round( bars ) ) > 1 / 16f )
			Log.Warning( $"Music clip '{sound.ResourcePath}' is {bars:0.00} bars at {DiamondMusicMixer.Bpm} BPM; it loops at {Math.Max( 1, MathF.Round( bars ) )} bars" );
	}

	/// <summary>Linear-resample to 44.1 kHz, preserving mono/stereo. Matching PCM is already owned by us.</summary>
	static short[] Resample( short[] source, int channels, int rate )
	{
		int frames = source.Length / channels;
		if ( rate <= 0 ) rate = DiamondMusicMixer.Rate;
		if ( rate == DiamondMusicMixer.Rate ) return source;
		int length = (int)((long)frames * DiamondMusicMixer.Rate / rate);
		var result = new short[length * channels];
		double step = rate / (double)DiamondMusicMixer.Rate;
		for ( int i = 0; i < length; i++ )
		{
			double position = i * step;
			int a = Math.Min( (int)position, frames - 1 );
			int b = Math.Min( a + 1, frames - 1 );
			float t = (float)(position - a);
			for ( int channel = 0; channel < channels; channel++ )
			{
				result[i * channels + channel] = (short)(source[a * channels + channel] * (1 - t) + source[b * channels + channel] * t);
			}
		}
		return result;
	}

	/// <summary>Multi-line state for the editor debug overlay.</summary>
	public string DebugText( DiamondBoard board )
	{
		var text = new System.Text.StringBuilder();
		long frames = Math.Max( 0, mixer.RunFrames );
		long bar = frames / DiamondMusicMixer.BarFrames;
		float beat = frames % DiamondMusicMixer.BarFrames / (DiamondMusicMixer.BarFrames / 4f) + 1;
		var (tier1, tier2, tier3) = TierPieces;
		string target = mixer.RequestedTier == mixer.Tier ? "" : mixer.RequestedTier > mixer.Tier ? $" -> {mixer.RequestedTier} next phrase" : $" -> {mixer.RequestedTier} next bar";
		text.AppendLine( $"MUSIC  {DiamondMusicMixer.Bpm} BPM  phrase {bar / DiamondMusicMixer.BarsPerPhrase + 1}  bar {bar % DiamondMusicMixer.BarsPerPhrase + 1}/{DiamondMusicMixer.BarsPerPhrase}  beat {beat:0.0}" );
		text.AppendLine( $"motif  {(string.IsNullOrEmpty( mixer.Motif ) ? "(none)" : mixer.Motif)}" );
		if ( mixer.Hold ) target = "  (held: game over)";
		if ( menuRun ) target = "  (menu)";
		text.AppendLine( $"tier   {mixer.Tier}{target}{(TierOverride >= 0 && !menuRun ? "  (override)" : "")}" );
		text.AppendLine( $"pieces {board.Placed}  (tiers at {tier1}/{tier2}/{tier3})" );
		text.AppendLine();
		foreach ( var layer in Enum.GetValues<DiamondMusicLayer>() )
		{
			var track = mixer.Playing( layer );
			var fading = mixer.Outgoing( layer );
			string clip = track is null ? "-" : $"{Name( track ),-22}bar {mixer.Position( layer ) / DiamondMusicMixer.BarFrames + 1}/{track.LoopFrames / DiamondMusicMixer.BarFrames}{(track.Clip.VolumeDb != 0 ? $"  {track.Clip.VolumeDb:+0.#;-0.#} dB" : "")}{(mixer.Gain( layer ) < 1 ? $"  fade {mixer.Gain( layer ):0.00}" : "")}";
			text.AppendLine( $"{layer,-8}{clip}{(fading is null ? "" : $"  (fading {Name( fading )})")}" );
		}
		text.AppendLine();
		int configured = Clips?.Count ?? 0;
		string stream = handle is not null && handle.IsValid && handle.IsPlaying ? "playing" : "stopped";
		text.AppendLine( $"clips  {mixer.Tracks.Count}/{configured} loaded{(loading.Count > 0 ? $", {loading.Count} decoding" : "")}  swap {SwapChance:P0} x{SwapFalloff:0.##} per swap, every {DiamondMusicMixer.SwapBars} bars" );
		text.Append( $"mix    master {mixer.MasterGain:0.00}  duck {mixer.DuckGain:0.00}  muffle {mixer.Muffle:0.00}  stream {stream}" );
		return text.ToString();
	}

	static string Name( DiamondMusicMixer.Track track ) => track.Clip.Sound?.ResourceName ?? "(unnamed)";

	void Pump()
	{
		if ( handle is null || !handle.IsValid )
		{
			if ( sinceStartFailed < 2 ) return;
			Start();
			if ( handle is null ) { sinceStartFailed = 0; return; }
		}
		// Keep a short lead: the mixer clock only advances by what is written, so frame
		// timing can never shift the beat, and changes are heard within ~0.1 s.
		int frames = (int)(DiamondMusicMixer.Rate * BufferSeconds) - stream.QueuedSampleCount;
		int limit = stream.MaxWriteSampleCount;
		if ( limit > 0 ) frames = Math.Min( frames, limit );
		if ( frames <= 0 ) return;
		int length = frames * 2;
		if ( mix.Length < length ) { mix = new float[length]; pcm = new short[length]; }
		var samples = mix.AsSpan( 0, length );
		mixer.Render( samples );
		for ( int i = 0; i < length; i++ ) pcm[i] = (short)(samples[i] * short.MaxValue);
		stream.WriteData( pcm.AsSpan( 0, length ) );
	}

	void Start()
	{
		Stop();
		stream = new SoundStream( DiamondMusicMixer.Rate, 2 );
		handle = stream.Play();
		if ( handle is null || !handle.IsValid ) { Stop(); return; }
		handle.ListenLocal = true;
		handle.SpacialBlend = 0;
		handle.DistanceAttenuation = false;
		handle.OcclusionEnabled = false;
		handle.ReverbEnabled = false;
		handle.AirAbsorption = false;
		handle.TargetMixer = Mixer.FindMixerByName( "Music" ) ?? Mixer.Default;
	}

	public void Stop()
	{
		handle?.Stop();
		handle = null;
		stream?.Dispose();
		stream = null;
	}
}