SpriteCatalog.cs
namespace NoChillquarium;

/// <summary>
/// Loads game sprites as <see cref="Texture"/> for UI panels.
/// Runtime uses embedded PNG bytes for small sprites. Large plates
/// (<c>loc_*</c>, <c>stand_*</c>, <c>ui_trailer_lot</c>, wallpaper) load from
/// package files — those globs must stay in <c>.sbproj</c> Resources.
/// </summary>
public static class SpriteCatalog
{
	static readonly Dictionary<string, Texture> _textures = new( StringComparer.OrdinalIgnoreCase );
	static readonly Dictionary<string, bool> _failed = new( StringComparer.OrdinalIgnoreCase );
	static int _version;
	static bool _quietLoad;

	static readonly Dictionary<string, string> FishPaths = new( StringComparer.OrdinalIgnoreCase )
	{
		["goldfish"] = "sprites/fish/goldfish.png",
		["guppy"] = "sprites/fish/guppy.png",
		["molly"] = "sprites/fish/molly.png",
		["koi"] = "sprites/fish/koi.png",
		["angelfish"] = "sprites/fish/angelfish.png",
		["piranha"] = "sprites/fish/piranha.png",
		["oscar"] = "sprites/fish/oscar.png",
		["axolotl"] = "sprites/fish/axolotl.png",
		["seahorse"] = "sprites/fish/seahorse.png",
		["puffer"] = "sprites/fish/puffer.png",
		["lionfish"] = "sprites/fish/lionfish.png",
		["mandarin"] = "sprites/fish/mandarin.png",
		["barracuda"] = "sprites/fish/barracuda.png",
		["molly_fat"] = "sprites/fish/molly_fat.png",
		["koi_fat"] = "sprites/fish/koi_fat.png",
		["angelfish_fat"] = "sprites/fish/angelfish_fat.png",
		["piranha_fat"] = "sprites/fish/piranha_fat.png",
		["oscar_fat"] = "sprites/fish/oscar_fat.png",
		["axolotl_fat"] = "sprites/fish/axolotl_fat.png",
		["seahorse_fat"] = "sprites/fish/seahorse_fat.png",
		["puffer_fat"] = "sprites/fish/puffer_fat.png",
		["lionfish_fat"] = "sprites/fish/lionfish_fat.png",
		["mandarin_fat"] = "sprites/fish/mandarin_fat.png",
		["barracuda_fat"] = "sprites/fish/barracuda_fat.png",
		["guppy_fat"] = "sprites/fish/guppy_fat.png",
		["neon_tetra"] = "sprites/fish/neon_tetra.png",
		["betta"] = "sprites/fish/betta.png",
		["clownfish"] = "sprites/fish/clownfish.png",
		["blue_tang"] = "sprites/fish/blue_tang.png",
		["cocaine_shark"] = "sprites/fish/cocaine_shark.png",
		// Overfeed fat sheets (swap in at FatStage 2+)
		["goldfish_fat"] = "sprites/fish/goldfish_fat.png",
		["neon_tetra_fat"] = "sprites/fish/neon_tetra_fat.png",
		["betta_fat"] = "sprites/fish/betta_fat.png",
		["clownfish_fat"] = "sprites/fish/clownfish_fat.png",
		["blue_tang_fat"] = "sprites/fish/blue_tang_fat.png",
		["monster_01"] = "sprites/fish/monster_01.png",
		["monster_tentacle"] = "sprites/fish/monster_tentacle.png",
		["monster_02"] = "sprites/fish/monster_02.png",
		["monster_03"] = "sprites/fish/monster_03.png",
		["monster_04"] = "sprites/fish/monster_04.png",
		["monster_05"] = "sprites/fish/monster_05.png",
		["monster_06"] = "sprites/fish/monster_06.png",
		["monster_07"] = "sprites/fish/monster_07.png",
		["monster_08"] = "sprites/fish/monster_08.png",
		["monster_09"] = "sprites/fish/monster_09.png",
		["monster_10"] = "sprites/fish/monster_10.png",
		["monster_01_fat"] = "sprites/fish/monster_01_fat.png",
		["monster_02_fat"] = "sprites/fish/monster_02_fat.png",
		["monster_03_fat"] = "sprites/fish/monster_03_fat.png",
		["monster_04_fat"] = "sprites/fish/monster_04_fat.png",
		["monster_05_fat"] = "sprites/fish/monster_05_fat.png",
		["monster_06_fat"] = "sprites/fish/monster_06_fat.png",
		["monster_07_fat"] = "sprites/fish/monster_07_fat.png",
		["monster_08_fat"] = "sprites/fish/monster_08_fat.png",
		["monster_09_fat"] = "sprites/fish/monster_09_fat.png",
		["monster_10_fat"] = "sprites/fish/monster_10_fat.png",
		["monster_goldfish"] = "sprites/fish/monster_goldfish.png",
		["monster_betta"] = "sprites/fish/monster_betta.png",
		["monster_neon"] = "sprites/fish/monster_neon.png",
		["monster_clown"] = "sprites/fish/monster_clown.png",
		["monster_tang"] = "sprites/fish/monster_tang.png",
		["monster_goldfish_fat"] = "sprites/fish/monster_goldfish_fat.png",
		["monster_betta_fat"] = "sprites/fish/monster_betta_fat.png",
		["monster_neon_fat"] = "sprites/fish/monster_neon_fat.png",
		["monster_clown_fat"] = "sprites/fish/monster_clown_fat.png",
		["monster_tang_fat"] = "sprites/fish/monster_tang_fat.png",
		["hybrid_plant"] = "sprites/fish/hybrid_plant.png",
		["hybrid_chest"] = "sprites/fish/hybrid_chest.png",
		["hybrid_bubble"] = "sprites/fish/hybrid_bubble.png",
		["hybrid_forty"] = "sprites/fish/hybrid_forty.png",
		["hybrid_cuffs"] = "sprites/fish/hybrid_cuffs.png",
		["hybrid_boot"] = "sprites/fish/hybrid_boot.png",
		["hybrid_meds"] = "sprites/fish/hybrid_meds.png",
		["hybrid_doge"] = "sprites/fish/hybrid_doge.png",
		["hybrid_m80"] = "sprites/fish/hybrid_m80.png",
		["monster_meth_1"] = "sprites/fish/monster_meth_1.png",
		["monster_meth_2"] = "sprites/fish/monster_meth_2.png",
		["monster_meth_3"] = "sprites/fish/monster_meth_3.png",
		["monster_meth_4"] = "sprites/fish/monster_meth_4.png",
		["monster_meth_5"] = "sprites/fish/monster_meth_5.png",
		["evo_gold_betta"] = "sprites/fish/evo_gold_betta.png",
		["evo_gold_betta_apex"] = "sprites/fish/evo_gold_betta_apex.png",
		["evo_gold_betta_fat"] = "sprites/fish/evo_gold_betta_fat.png",
		["evo_gold_betta_apex_fat"] = "sprites/fish/evo_gold_betta_apex_fat.png",
	};

	static readonly Dictionary<string, string> DecorPaths = new( StringComparer.OrdinalIgnoreCase )
	{
		["plant_green"] = "sprites/decor/plant_green.png",
		["treasure_chest"] = "sprites/decor/treasure_chest.png",
		["bubble_wand"] = "sprites/decor/bubble_wand.png",
		["forty_oz"] = "sprites/decor/forty_oz.png",
		["handcuffs_skeleton"] = "sprites/decor/handcuffs_skeleton.png",
		["old_boot"] = "sprites/decor/old_boot.png",
		["medicine_bottles"] = "sprites/decor/medicine_bottles.png",
		["doge_statue"] = "sprites/decor/doge_statue.png",
		["m80_crate"] = "sprites/decor/m80_crate.png",
		["rock_pile"] = "sprites/decor/rock_pile.png",
		["plastic_castle"] = "sprites/decor/plastic_castle.png",
		["rusty_anchor"] = "sprites/decor/rusty_anchor.png",
		["road_cone"] = "sprites/decor/road_cone.png",
		["lava_lamp"] = "sprites/decor/lava_lamp.png",
		["pizza_box"] = "sprites/decor/pizza_box.png",
		["cheap_trophy"] = "sprites/decor/cheap_trophy.png",
		["fake_coral"] = "sprites/decor/fake_coral.png",
		["bg_newspaper"] = "sprites/decor/bg_newspaper.png",
		["bg_magazines"] = "sprites/decor/bg_magazines.png",
		["bg_aqua_poster"] = "sprites/decor/bg_aqua_poster.png",
		["bg_reef_poster"] = "sprites/decor/bg_reef_poster.png",
		["bg_value_menu"] = "sprites/decor/bg_value_menu.png",
		["bg_greek_ruins"] = "sprites/decor/bg_greek_ruins.png",
		["bg_wood_panel"] = "sprites/decor/bg_wood_panel.png",
		["bg_leopard"] = "sprites/decor/bg_leopard.png",
		["gravel_brown"] = "sprites/decor/gravel_brown.png",
		["gravel_neon"] = "sprites/decor/gravel_neon.png",
		["gravel_black"] = "sprites/decor/gravel_black.png",
		["gravel_blue"] = "sprites/decor/gravel_blue.png",
		["gravel_gold"] = "sprites/decor/gravel_gold.png",
		["gravel_white"] = "sprites/decor/gravel_white.png",
		["gravel_rainbow"] = "sprites/decor/gravel_rainbow.png",
		["gravel_slime"] = "sprites/decor/gravel_slime.png",
	};

	static readonly Dictionary<string, string> UiPaths = new( StringComparer.OrdinalIgnoreCase )
	{
		["wallet_font"] = "sprites/ui/wallet_font.png",
		["inv_food"] = "sprites/ui/inv_food.png",
		["inv_bloodworms"] = "sprites/ui/inv_bloodworms.png",
		["pellet_bloodworm"] = "sprites/ui/pellet_bloodworm.png",
		["inv_m80"] = "sprites/ui/inv_m80.png",
		["inv_syringe"] = "sprites/ui/inv_syringe.png",
		["inv_meth"] = "sprites/ui/inv_meth.png",
		["menu_wallpaper"] = "sprites/ui/menu_wallpaper.png",
		["ui_laptop"] = "sprites/ui/ui_laptop.png",
		["ui_fish_counter"] = "sprites/ui/ui_fish_counter.png",
		["ui_grandma"] = "sprites/ui/ui_grandma.png",
		["ui_janitor"] = "sprites/ui/ui_janitor.png",
		["ui_bully"] = "sprites/ui/ui_bully.png",
		["ui_you"] = "sprites/ui/ui_you.png",
		["adv_mite"] = "sprites/ui/adv_mite.png",
		["adv_eel"] = "sprites/ui/adv_eel.png",
		["adv_carp"] = "sprites/ui/adv_carp.png",
		["adv_valve"] = "sprites/ui/adv_valve.png",
		["adv_grate"] = "sprites/ui/adv_grate.png",
		["adv_coin"] = "sprites/ui/adv_coin.png",
		["adv_cg_janitor"] = "sprites/ui/adv_cg_janitor.png",
		["adv_cg_grandma"] = "sprites/ui/adv_cg_grandma.png",
		["adv_cg_brad"] = "sprites/ui/adv_cg_brad.png",
		["adv_cg_you"] = "sprites/ui/adv_cg_you.png",
		["adv_cg_flush"] = "sprites/ui/adv_cg_flush.png",
		["adv_cg_faucet"] = "sprites/ui/adv_cg_faucet.png",
		["adv_cg_principal"] = "sprites/ui/adv_cg_principal.png",
		["adv_cg_loot"] = "sprites/ui/adv_cg_loot.png",
		["adv_cg_hazard"] = "sprites/ui/adv_cg_hazard.png",
		["adv_cg_boss"] = "sprites/ui/adv_cg_boss.png",
		["adv_cg_mite"] = "sprites/ui/adv_cg_mite.png",
		["ui_trailer_lot"] = "sprites/ui/ui_trailer_lot.png",
		["loc_porch"] = "sprites/ui/loc_porch.png",
		["loc_bathroom"] = "sprites/ui/loc_bathroom.png",
		["loc_yard"] = "sprites/ui/loc_yard.png",
		["loc_neighbor"] = "sprites/ui/loc_neighbor.png",
		["loc_lake"] = "sprites/ui/loc_lake.png",
		["loc_sewer"] = "sprites/ui/loc_sewer.png",
		["loc_gulf"] = "sprites/ui/loc_gulf.png",
		["loc_school"] = "sprites/ui/loc_school.png",
		["loc_alley"] = "sprites/ui/loc_alley.png",
		["loc_gas"] = "sprites/ui/loc_gas.png",
		["loc_bingo"] = "sprites/ui/loc_bingo.png",
		["stand_cinder"] = "sprites/ui/stand_cinder.png",
		["stand_porch"] = "sprites/ui/stand_porch.png",
		["stand_bathroom"] = "sprites/ui/stand_bathroom.png",
		["stand_yard"] = "sprites/ui/stand_yard.png",
		["stand_neighbor"] = "sprites/ui/stand_neighbor.png",
		["stand_lake"] = "sprites/ui/stand_lake.png",
		["stand_sewer"] = "sprites/ui/stand_sewer.png",
		["stand_gulf"] = "sprites/ui/stand_gulf.png",
		["stand_school"] = "sprites/ui/stand_school.png",
		["stand_alley"] = "sprites/ui/stand_alley.png",
		["stand_gas"] = "sprites/ui/stand_gas.png",
		["stand_bingo"] = "sprites/ui/stand_bingo.png",
		["stand_dock"] = "sprites/ui/stand_dock.png",
		["stand_front_porch"] = "sprites/ui/stand_front_porch.png",
		["stand_front_tile"] = "sprites/ui/stand_front_tile.png",
		["stand_front_pool"] = "sprites/ui/stand_front_pool.png",
		["stand_front_dock"] = "sprites/ui/stand_front_dock.png",
		["stand_front_pipe"] = "sprites/ui/stand_front_pipe.png",
		["water_drop"] = "sprites/ui/water_drop.png",
		["water_blob"] = "sprites/ui/water_blob.png",
		["water_splash"] = "sprites/ui/water_splash.png",
		["water_jet"] = "sprites/ui/water_jet.png",
		// Glass stress (meth-rush miss) — clean transparent line art
		["crack_a"] = "sprites/ui/crack_a.png",
		["crack_b"] = "sprites/ui/crack_b.png",
		["crack_c"] = "sprites/ui/crack_c.png",
		["crack_d"] = "sprites/ui/crack_d.png",
		["crack_spider"] = "sprites/ui/crack_spider.png",
	};

	public static int Version => _version;

	static readonly Dictionary<string, int> _swimFrames = new( StringComparer.OrdinalIgnoreCase );

	/// <summary>How many <c>{stem}_swim_0..N</c> frames exist (0 = still only).</summary>
	public static int SwimFrameCount( string stem )
	{
		if ( string.IsNullOrWhiteSpace( stem ) )
			return 0;
		if ( _swimFrames.TryGetValue( stem, out var cached ) )
			return cached;
		var n = 0;
		while ( n < 4 && Has( stem + "_swim_" + n ) )
			n++;
		_swimFrames[stem] = n;
		return n;
	}

	public static bool Has( string idOrFile ) => GetTexture( idOrFile ) is not null;

	public static Texture GetTexture( string idOrFile )
	{
		if ( string.IsNullOrWhiteSpace( idOrFile ) )
			return null;

		var key = NormalizeKey( idOrFile );
		if ( _textures.TryGetValue( key, out var cached ) && cached is not null )
			return cached;

		if ( _failed.ContainsKey( key ) )
			return null;

		// Small sprites: embeds (always in the DLL). Large plates: raw PNG bytes
		// (need .sbproj Resources) then compiled Texture.Load.
		var tex = TryCreateFromEmbedded( key ) ?? TryCreateFromFile( key ) ?? TryLoadPackageTexture( key );
		if ( tex is not null )
		{
			_textures[key] = tex;
			_version++;
			return tex;
		}

		_failed[key] = true;
		_version++;
		if ( !_quietLoad )
			Log.Warning( $"[NO-CHILLquarium] Sprite FAILED: {key}" );
		return null;
	}

	public static void Warmup()
	{
		_textures.Clear();
		_failed.Clear();
		_swimFrames.Clear();
		_quietLoad = true;

		var total = 0;
		var ok = 0;
		foreach ( var k in FishPaths.Keys )
		{
			total++;
			if ( GetTexture( k ) is not null )
				ok++;
		}
		foreach ( var k in DecorPaths.Keys )
		{
			total++;
			if ( GetTexture( k ) is not null )
				ok++;
		}
		foreach ( var k in UiPaths.Keys )
		{
			total++;
			if ( GetTexture( k ) is not null )
				ok++;
		}

		_quietLoad = false;
		Log.Info( $"[NO-CHILLquarium] {GameInfo.VersionLabel} sprites {ok}/{total}" );
		if ( ok < total )
		{
			var missing = _failed.Keys.OrderBy( k => k ).ToArray();
			Log.Warning( $"[NO-CHILLquarium] {total - ok} sprite(s) missing: {string.Join( ", ", missing )} — embeds or .sbproj Resources." );
		}
	}

	static string NormalizeKey( string idOrFile )
	{
		var s = idOrFile.Trim().Replace( '\\', '/' );
		if ( s.Contains( '/' ) )
		{
			var name = s.Split( '/' )[^1];
			if ( name.EndsWith( ".png", StringComparison.OrdinalIgnoreCase ) )
				name = name[..^4];
			if ( name.EndsWith( ".sprite", StringComparison.OrdinalIgnoreCase ) )
				name = name[..^7];
			return name;
		}
		if ( s.EndsWith( ".png", StringComparison.OrdinalIgnoreCase ) )
			s = s[..^4];
		if ( s.EndsWith( ".sprite", StringComparison.OrdinalIgnoreCase ) )
			s = s[..^7];
		return s;
	}

	static string ResolvePath( string key )
	{
		if ( FishPaths.TryGetValue( key, out var fp ) )
			return fp;
		if ( DecorPaths.TryGetValue( key, out var dp ) )
			return dp;
		if ( UiPaths.TryGetValue( key, out var up ) )
			return up;
		return null;
	}

	static Texture TextureFromPngBytes( string key, byte[] bytes )
	{
		if ( bytes is null || bytes.Length < 8 )
			return null;
		try
		{
			var bmp = Bitmap.CreateFromBytes( bytes );
			if ( bmp is null )
				return null;

			var size = bmp.Size;
			var w = Math.Max( 1, (int)MathF.Round( size.x ) );
			var h = Math.Max( 1, (int)MathF.Round( size.y ) );

			var tex = Texture.Create( w, h )
				.WithName( $"nochill_{key}" )
				.Finish();
			if ( tex is null )
				return null;

			tex.Update( bmp );
			return tex;
		}
		catch ( Exception e )
		{
			if ( !_quietLoad )
				Log.Warning( $"[NO-CHILLquarium] Sprite bytes {key}: {e.Message}" );
			return null;
		}
	}

	/// <summary>
	/// Large loc/stand plates are not embedded (CS8103). Read PNG bytes from the mount.
	/// </summary>
	static Texture TryCreateFromFile( string key )
	{
		foreach ( var candidate in FileCandidates( key ) )
		{
			try
			{
				if ( !FileSystem.Mounted.FileExists( candidate ) )
					continue;
				// s&box ReadAllBytes is a Span — copy to a real array for Bitmap.
				var bytes = FileSystem.Mounted.ReadAllBytes( candidate ).ToArray();
				var tex = TextureFromPngBytes( key, bytes );
				if ( tex is not null )
					return tex;
			}
			catch
			{
				// next path
			}
		}
		return null;
	}

	static IEnumerable<string> FileCandidates( string key )
	{
		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		void Add( string s )
		{
			if ( string.IsNullOrEmpty( s ) )
				return;
			seen.Add( s.Replace( '\\', '/' ) );
		}

		Add( "ui/sprites/" + key + ".png" );
		Add( "sprites/ui/" + key + ".png" );
		Add( "assets/ui/sprites/" + key + ".png" );
		Add( "assets/sprites/ui/" + key + ".png" );
		var path = ResolvePath( key );
		if ( !string.IsNullOrEmpty( path ) )
		{
			Add( path );
			Add( "assets/" + path.TrimStart( '/' ) );
		}

		foreach ( var s in seen )
			yield return s;
	}

	static Texture TryLoadPackageTexture( string key )
	{
		// Compiled textures + Resource Files. warnOnMissing:false avoids console spam
		// for candidates that simply aren't in this package.
		foreach ( var candidate in TextureCandidates( key ) )
		{
			try
			{
				var tex = Texture.Load( candidate, false );
				if ( tex.IsValid() && tex.Width > 1 && tex.Height > 1 )
					return tex;
			}
			catch
			{
				// next path
			}

			try
			{
#pragma warning disable CS0618
				var tex = Texture.Load( FileSystem.Mounted, candidate, false );
#pragma warning restore CS0618
				if ( tex.IsValid() && tex.Width > 1 && tex.Height > 1 )
					return tex;
			}
			catch
			{
				// next path
			}
		}

		return null;
	}

	static IEnumerable<string> TextureCandidates( string key )
	{
		var seen = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
		void Add( string s )
		{
			if ( string.IsNullOrEmpty( s ) )
				return;
			seen.Add( s.Replace( '\\', '/' ) );
		}

		var path = ResolvePath( key );
		if ( !string.IsNullOrEmpty( path ) )
		{
			Add( path );
			if ( !path.EndsWith( ".png", StringComparison.OrdinalIgnoreCase ) )
				Add( path + ".png" );
			Add( "assets/" + path.TrimStart( '/' ) );
		}

		Add( "sprites/ui/" + key + ".png" );
		Add( "ui/sprites/" + key + ".png" );
		Add( "sprites/fish/" + key + ".png" );
		Add( "sprites/decor/" + key + ".png" );
		Add( "sprites/ui/" + key + ".sprite" );
		Add( "sprites/fish/" + key + ".sprite" );
		Add( "sprites/decor/" + key + ".sprite" );

		foreach ( var s in seen )
			yield return s;
	}

	static Texture TryCreateFromEmbedded( string key )
	{
		if ( !SpriteEmbedded.PngBase64.TryGetValue( key, out var b64 ) || string.IsNullOrEmpty( b64 ) )
			return null;

		byte[] bytes;
		try
		{
			bytes = Convert.FromBase64String( b64 );
		}
		catch
		{
			return null;
		}

		try
		{
			return TextureFromPngBytes( key, bytes );
		}
		catch ( Exception e )
		{
			if ( !_quietLoad )
				Log.Warning( $"[NO-CHILLquarium] Embed texture failed ({key}): {e.Message}" );
			return null;
		}
	}

	public static void Invalidate( string key )
	{
		if ( string.IsNullOrWhiteSpace( key ) )
			return;
		key = NormalizeKey( key );
		_textures.Remove( key );
		_failed.Remove( key );
		_version++;
	}
}