ExplodeSystem.cs
namespace NoChillquarium;

public sealed class BoomParticle
{
	public float X { get; set; }
	public float Y { get; set; }
	public float Vx { get; set; }
	public float Vy { get; set; }
	public float Life { get; set; }
	public string Color { get; set; }
}

/// <summary>
/// M80 tape-and-explode: click fish to tape a visible M80 on them, then detonate.
/// </summary>
public static class ExplodeSystem
{
	static readonly Dictionary<string, float> _taped = new(); // instanceId -> time since taped
	static readonly List<BoomParticle> _particles = new();
	static readonly Random _rng = new();
	static int _version;
	static bool _mode;

	public static bool ModeActive
	{
		get => _mode;
		set
		{
			_mode = value;
			if ( !_mode )
				_taped.Clear();
			_version++;
		}
	}

	public static int SelectedCount => _taped.Count;
	public static int Version => _version;
	public static IReadOnlyList<BoomParticle> Particles => _particles;

	public static string StatusText =>
		!_mode
			? (Shop.M80Bags > 0 ? $"M80 ×{Shop.M80Bags}" : "NO M80 — BUY IN SHADY")
			: SelectedCount == 0
				? $"CLICK FISH TO TAPE M80 ({Shop.M80Bags} left)"
				: $"M80 TAPED ×{SelectedCount}/{Shop.M80Bags} · HIT DETONATE";

	public static void Clear()
	{
		_mode = false;
		_taped.Clear();
		_particles.Clear();
		_version++;
	}

	/// <summary>Enter/leave arm mode. Requires at least one bought M80 to arm.</summary>
	public static bool TrySetMode( bool active )
	{
		if ( active && !Shop.CanArmM80() )
		{
			GameAudio.PlayUi( GameAudio.Error );
			TankSim.ShowBanner( "No M80s. Buy them on the Shady tab." );
			return false;
		}

		ModeActive = active;
		return true;
	}

	public static bool IsSelected( string instanceId ) =>
		!string.IsNullOrEmpty( instanceId ) && _taped.ContainsKey( instanceId );

	/// <summary>Remove tape from one fish (e.g. moved to another tank).</summary>
	public static void Untape( string instanceId )
	{
		if ( string.IsNullOrEmpty( instanceId ) )
			return;
		if ( _taped.Remove( instanceId ) )
			_version++;
	}

	/// <summary>0—1, hot for a moment after tape so UI can pop the prop in.</summary>
	public static float TapeFreshness( string instanceId )
	{
		if ( string.IsNullOrEmpty( instanceId ) || !_taped.TryGetValue( instanceId, out var age ) )
			return 0f;
		// Fresh for ~0.45s
		return Math.Clamp( 1f - (age / 0.45f), 0f, 1f );
	}

	public static bool IsFreshlyTaped( string instanceId ) => TapeFreshness( instanceId ) > 0.05f;

	public static void ToggleSelect( string instanceId )
	{
		if ( !_mode || string.IsNullOrEmpty( instanceId ) )
			return;

		// Must still exist in the tank.
		if ( TankSim.Fish.All( f => f.InstanceId != instanceId ) )
			return;

		if ( _taped.ContainsKey( instanceId ) )
		{
			_taped.Remove( instanceId );
			GameAudio.PlayUi( GameAudio.Close );
		}
		else
		{
			// One stick per fish — can't tape more than you bought.
			if ( Shop.M80Bags <= _taped.Count )
			{
				GameAudio.PlayUi( GameAudio.Error );
				TankSim.ShowBanner( Shop.M80Bags <= 0
					? "Out of M80s. Buy more in Shady."
					: $"Only {Shop.M80Bags} M80{(Shop.M80Bags == 1 ? "" : "s")} left." );
				return;
			}

			_taped[instanceId] = 0f;
			// The moment of comedy: tape slap onto the fish.
			GameAudio.PlayUi( GameAudio.Tape );
			AchievementSystem.Unlock( "tape" );
		}

		_version++;
	}

	public static bool TryDetonate()
	{
		if ( !_mode )
		{
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		if ( _taped.Count == 0 )
		{
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		var victims = TankSim.Fish.Where( f => _taped.ContainsKey( f.InstanceId ) ).ToList();
		if ( victims.Count == 0 )
		{
			_taped.Clear();
			_version++;
			GameAudio.PlayUi( GameAudio.Error );
			return false;
		}

		// Spend one M80 per taped fish.
		if ( !Shop.TryConsumeM80( victims.Count ) )
		{
			GameAudio.PlayUi( GameAudio.Error );
			TankSim.ShowBanner( "Not enough M80s in stock." );
			return false;
		}

		// Fuse already taped — this is the boom.
		GameAudio.PlayUi( GameAudio.M80 );

		var origins = new List<(float X, float Y)>( victims.Count );
		var scrap = 0.0;
		var failures = 0;
		var keepers = 0;
		foreach ( var fish in victims )
		{
			var ox = fish.X;
			var oy = fish.DrawY;
			origins.Add( (ox, oy) );
			SpawnBurst( ox, oy, fish.Species.Color, fish.Species.FinColor );
			// Extra red stick bits for the M80 itself.
			SpawnM80Bits( ox, oy );
			// Fire / ash particles for comedy violence.
			SpawnFireRing( ox, oy );

			// Failures = insurance scrap. Good aboms you blow up = waste.
			if ( fish.IsAbominationFailure )
			{
				failures++;
				// Quality control: scrap a chunk of what they'd flip for.
				scrap += Math.Max( 8.0, TankSim.SellPrice( fish ) * 0.55 );
			}
			else if ( fish.IsMonster )
			{
				keepers++;
				scrap += Math.Max( 3.0, TankSim.SellPrice( fish ) * 0.12 );
			}
			else
			{
				scrap += 2.5;
			}

			TankSim.RemoveFish( fish.InstanceId );
		}

		var n = victims.Count;
		_taped.Clear();

		// Screen smash + shock rings at each fish + death metal sting.
		BoomFeel.Trigger( n, origins );
		foreach ( var o in origins )
			WaterFx.BoomSplash( o.X, o.Y );

		Chaos.Add( 6f + n * 5f );
		// Grandma respects scrapping failures; hates wasting champions.
		if ( failures > 0 )
			Karma.Add( 2.5f * failures );
		if ( keepers > 0 )
			Karma.Add( -5f * keepers );
		if ( failures == 0 && keepers == 0 )
			Karma.Add( -3f - n * 2f );

		scrap = Math.Round( scrap, 1 );
		if ( scrap > 0 )
			Economy.Add( scrap );

		if ( failures > 0 && keepers == 0 )
			TankSim.ShowBanner( $"Scrapped {failures} failure{(failures == 1 ? "" : "s")}. Grandma: \"Quality control. Family business.\" +Ð{Economy.FormatDoge( scrap )}" );
		else if ( keepers > 0 )
			TankSim.ShowBanner( $"Boom. Grandma: \"That one had potential, idiot. Love you. Sit.\" +Ð{Economy.FormatDoge( scrap )}" );
		else
			TankSim.ShowBanner( $"Boom ×{n}. +Ð{Economy.FormatDoge( scrap )}" );

		// Always return to default dock mode after the boom (clear via property).
		ModeActive = false;

		StorySystem.NotifyBoom();
		JanitorPeep.On( "boom" );
		SaveGame.TrySave();
		return true;
	}

	public static void Tick( float dt )
	{
		if ( dt <= 0f )
			return;

		if ( dt > 0.05f )
			dt = 0.05f;

		if ( _taped.Count > 0 )
		{
			// Age tape timers (for "just taped" pop animation).
			var keys = _taped.Keys.ToList();
			foreach ( var key in keys )
			{
				_taped[key] += dt;
				// Drop selection if fish was removed somehow.
				if ( TankSim.Fish.All( f => f.InstanceId != key ) )
					_taped.Remove( key );
			}

			_version++;
		}

		for ( var i = _particles.Count - 1; i >= 0; i-- )
		{
			var p = _particles[i];
			p.X += p.Vx * dt;
			p.Y += p.Vy * dt;
			p.Vy += 180f * dt;
			p.Life -= dt;
			if ( p.Life <= 0f )
				_particles.RemoveAt( i );
		}

		if ( _particles.Count > 0 )
			_version++;
	}

	/// <summary>Quiet little death poof for salinity (not M80 comedy).</summary>
	public static void SpawnDeathPoof( float x, float y )
	{
		for ( var i = 0; i < 10; i++ )
		{
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			var spd = 20f + (float)_rng.NextDouble() * 50f;
			_particles.Add( new BoomParticle
			{
				X = x,
				Y = y,
				Vx = MathF.Cos( ang ) * spd,
				Vy = MathF.Sin( ang ) * spd - 10f,
				Life = 0.4f + (float)_rng.NextDouble() * 0.5f,
				Color = i % 2 == 0 ? "#4a6040" : "#2a3020"
			} );
		}
		_version++;
	}

	/// <summary>Gold coin confetti for sells / big Ð moments (reuses boom bits pipeline).</summary>
	public static void SpawnCoinBurst( float x, float y, int count = 14 )
	{
		count = Math.Clamp( count, 6, 28 );
		for ( var i = 0; i < count; i++ )
		{
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			var spd = 50f + (float)_rng.NextDouble() * 140f;
			_particles.Add( new BoomParticle
			{
				X = x + (float)(_rng.NextDouble() * 10.0 - 5.0),
				Y = y + (float)(_rng.NextDouble() * 8.0 - 4.0),
				Vx = MathF.Cos( ang ) * spd,
				Vy = MathF.Sin( ang ) * spd - 40f - (float)_rng.NextDouble() * 40f,
				Life = 0.45f + (float)_rng.NextDouble() * 0.55f,
				Color = (i % 3) switch
				{
					0 => "#ffd24a",
					1 => "#ffe8a0",
					_ => "#c8a020"
				}
			} );
		}
		_version++;
	}

	/// <summary>Soft green feed sparkles when pellets land.</summary>
	public static void SpawnFeedSpark( float x, float y )
	{
		for ( var i = 0; i < 6; i++ )
		{
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			var spd = 25f + (float)_rng.NextDouble() * 55f;
			_particles.Add( new BoomParticle
			{
				X = x,
				Y = y,
				Vx = MathF.Cos( ang ) * spd,
				Vy = MathF.Sin( ang ) * spd - 20f,
				Life = 0.25f + (float)_rng.NextDouble() * 0.3f,
				Color = i % 2 == 0 ? "#c8ff90" : "#e8c060"
			} );
		}
		_version++;
	}

	static void SpawnBurst( float x, float y, string colorA, string colorB )
	{
		// Bigger paste cloud.
		for ( var i = 0; i < 28; i++ )
		{
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			var spd = 80f + (float)_rng.NextDouble() * 220f;
			_particles.Add( new BoomParticle
			{
				X = x,
				Y = y,
				Vx = MathF.Cos( ang ) * spd,
				Vy = MathF.Sin( ang ) * spd - 50f,
				Life = 0.4f + (float)_rng.NextDouble() * 0.7f,
				Color = (i % 2 == 0) ? colorA : colorB
			} );
		}

		// Hot white core.
		for ( var i = 0; i < 14; i++ )
		{
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			var spd = 60f + (float)_rng.NextDouble() * 160f;
			_particles.Add( new BoomParticle
			{
				X = x,
				Y = y,
				Vx = MathF.Cos( ang ) * spd,
				Vy = MathF.Sin( ang ) * spd,
				Life = 0.2f + (float)_rng.NextDouble() * 0.35f,
				Color = i % 3 == 0 ? "#ffffff" : "#ffe080"
			} );
		}
	}

	static void SpawnM80Bits( float x, float y )
	{
		for ( var i = 0; i < 10; i++ )
		{
			var ang = (float)(_rng.NextDouble() * Math.PI * 2);
			var spd = 70f + (float)_rng.NextDouble() * 130f;
			_particles.Add( new BoomParticle
			{
				X = x,
				Y = y,
				Vx = MathF.Cos( ang ) * spd,
				Vy = MathF.Sin( ang ) * spd - 30f,
				Life = 0.45f + (float)_rng.NextDouble() * 0.4f,
				Color = i % 2 == 0 ? "#c41e1e" : "#2a2a2a"
			} );
		}
	}

	static void SpawnFireRing( float x, float y )
	{
		const int n = 16;
		for ( var i = 0; i < n; i++ )
		{
			var ang = (i / (float)n) * MathF.PI * 2f;
			var spd = 140f + (float)_rng.NextDouble() * 40f;
			var color = (i % 3) switch
			{
				0 => "#ff4020",
				1 => "#ff9020",
				_ => "#ffd040"
			};
			_particles.Add( new BoomParticle
			{
				X = x,
				Y = y,
				Vx = MathF.Cos( ang ) * spd,
				Vy = MathF.Sin( ang ) * spd,
				Life = 0.35f + (float)_rng.NextDouble() * 0.25f,
				Color = color
			} );
		}
	}
}