UI/Library/LibretroDb.cs
using Emulotl.Gba;
using System.Text.RegularExpressions;
using System.Threading.Tasks;

namespace Emulotl;

public sealed record GameInfo(
	string Name,
	string Region,
	string Serial,
	string Developer,
	string Publisher,
	string ReleaseYear,
	string ReleaseMonth,
	string Genre,
	string Franchise,
	string EsrbRating,
	string MaxUsers
);

internal sealed class LibretroDb
{
	private const string BaseUrl = "https://raw.githubusercontent.com/libretro/libretro-database/master/metadat/";
	private const string CacheRoot = "database";
	private const string CacheDirectory = "database/metadat";

	private static readonly Dictionary<string, Task<FieldIndex>> FieldTasks = [];
	private static readonly Dictionary<string, Task<GameInfo>> LookupTasks = [];
	private static readonly Dictionary<string, Task<NoIntroIndex>> NoIntroTasks = [];

	public static async Task WarmAsync()
	{
		EmulatorSystems.EnsureRegistered();

		var tasks = new List<Task>();
		foreach ( SystemProfile profile in SystemRegistry.All )
		{
			string platform = profile.LibretroPlatform;
			if ( string.IsNullOrWhiteSpace( platform ) )
				continue;

			tasks.Add( GetNoIntroAsync( platform ) );
			tasks.Add( GetFieldIndexAsync( platform, "developer", "developer", true ) );
			tasks.Add( GetFieldIndexAsync( platform, "publisher", "publisher", true ) );
			tasks.Add( GetFieldIndexAsync( platform, "releaseyear", "releaseyear", true ) );
			tasks.Add( GetFieldIndexAsync( platform, "releasemonth", "releasemonth", true ) );
			tasks.Add( GetFieldIndexAsync( platform, "genre", "genre", true ) );
			tasks.Add( GetFieldIndexAsync( platform, "franchise", "franchise", true ) );
			tasks.Add( GetFieldIndexAsync( platform, "esrb", "esrb_rating", true ) );
			tasks.Add( GetFieldIndexAsync( platform, "maxusers", "users", false ) );
		}

		foreach ( var task in tasks )
			await task;
	}

	private static string PlatformOf( GameEntry game )
		=> SystemRegistry.ById( game?.SystemId )?.LibretroPlatform ?? GbaSystem.Profile.LibretroPlatform;

	public static Task<GameInfo> FetchAsync( GameEntry game, GameHashes hashes = null )
	{
		if ( game is null )
			return Task.FromResult<GameInfo>( null );

		if ( LookupTasks.TryGetValue( game.Path, out var task ) )
			return task;

		task = FetchInternalAsync( game, hashes );
		LookupTasks[game.Path] = task;
		return task;
	}

	private static async Task<GameInfo> FetchInternalAsync( GameEntry game, GameHashes hashes )
	{
		string platform = PlatformOf( game );
		var crc = NormalizeCrc( hashes?.Crc );

		var noIntro = await GetNoIntroAsync( platform );
		var noIntroEntry = FindNoIntroEntry( noIntro, game, crc );
		var developerTask = GetValueAsync( platform, "developer", "developer", true, game, noIntroEntry, crc );
		var publisherTask = GetValueAsync( platform, "publisher", "publisher", true, game, noIntroEntry, crc );
		var releaseYearTask = GetValueAsync( platform, "releaseyear", "releaseyear", true, game, noIntroEntry, crc );
		var releaseMonthTask = GetValueAsync( platform, "releasemonth", "releasemonth", true, game, noIntroEntry, crc );
		var genreTask = GetValueAsync( platform, "genre", "genre", true, game, noIntroEntry, crc );
		var franchiseTask = GetValueAsync( platform, "franchise", "franchise", true, game, noIntroEntry, crc );
		var esrbTask = GetValueAsync( platform, "esrb", "esrb_rating", true, game, noIntroEntry, crc );
		var maxUsersTask = GetValueAsync( platform, "maxusers", "users", false, game, noIntroEntry, crc );

		await developerTask;
		await publisherTask;
		await releaseYearTask;
		await releaseMonthTask;
		await genreTask;
		await franchiseTask;
		await esrbTask;
		await maxUsersTask;

		return new GameInfo(
			Display( noIntroEntry?.Name, game.NoIntroName, game.DisplayTitle ),
			Display( noIntroEntry?.Region, game.Region ),
			Display( noIntroEntry?.Serial, game.GameCode ),
			developerTask.Result,
			publisherTask.Result,
			releaseYearTask.Result,
			releaseMonthTask.Result,
			genreTask.Result,
			franchiseTask.Result,
			esrbTask.Result,
			maxUsersTask.Result
		);
	}

	private static async Task<string> GetValueAsync( string platform, string folder, string propertyName, bool quoted, GameEntry game, NoIntroEntry noIntroEntry, string crc )
	{
		var index = await GetFieldIndexAsync( platform, folder, propertyName, quoted );

		if ( !string.IsNullOrWhiteSpace( crc ) && index.ByCrc.TryGetValue( crc, out var value ) )
			return value;

		foreach ( var title in CandidateTitles( game, noIntroEntry ) )
		{
			var key = NormalizeTitle( title );
			if ( !string.IsNullOrWhiteSpace( key ) && index.ByTitle.TryGetValue( key, out value ) )
				return value;
		}

		return string.Empty;
	}

	private static Task<FieldIndex> GetFieldIndexAsync( string platform, string folder, string propertyName, bool quoted )
	{
		var key = $"{platform}:{folder}:{propertyName}";
		if ( FieldTasks.TryGetValue( key, out var task ) )
			return task;

		task = LoadFieldIndexAsync( platform, folder, propertyName, quoted );
		FieldTasks[key] = task;
		return task;
	}

	private static async Task<FieldIndex> LoadFieldIndexAsync( string platform, string folder, string propertyName, bool quoted )
	{
		try
		{
			var text = await LoadDatAsync( folder, platform );
			return ParseFieldIndex( text, propertyName, quoted );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[Emulotl] Libretro metadata {folder} failed: {ex.Message}" );
			return new FieldIndex();
		}
	}

	private static Task<NoIntroIndex> GetNoIntroAsync( string platform )
	{
		if ( NoIntroTasks.TryGetValue( platform, out var task ) )
			return task;

		task = LoadNoIntroAsync( platform );
		NoIntroTasks[platform] = task;
		return task;
	}

	private static async Task<NoIntroIndex> LoadNoIntroAsync( string platform )
	{
		try
		{
			var text = await LoadDatAsync( "no-intro", platform );
			return ParseNoIntroIndex( text );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[Emulotl] Libretro no-intro metadata failed: {ex.Message}" );
			return new NoIntroIndex();
		}
	}

	private static async Task<string> LoadDatAsync( string folder, string platform )
	{
		var cacheFileName = platform + ".dat";
		var urlFileName = Uri.EscapeDataString( platform ) + ".dat";
		var cachePath = $"{CacheDirectory}/{folder}/{cacheFileName}";
		try
		{
			if ( FileSystem.Data.FileExists( cachePath ) )
				return FileSystem.Data.ReadAllText( cachePath );
		}
		catch { }

		var text = await Http.RequestStringAsync( BaseUrl + folder + "/" + urlFileName );
		try
		{
			if ( !FileSystem.Data.DirectoryExists( CacheRoot ) )
				FileSystem.Data.CreateDirectory( CacheRoot );

			if ( !FileSystem.Data.DirectoryExists( CacheDirectory ) )
				FileSystem.Data.CreateDirectory( CacheDirectory );

			var folderPath = $"{CacheDirectory}/{folder}";
			if ( !FileSystem.Data.DirectoryExists( folderPath ) )
				FileSystem.Data.CreateDirectory( folderPath );

			FileSystem.Data.WriteAllText( cachePath, text );
		}
		catch ( Exception ex )
		{
			Log.Warning( $"[Emulotl] Failed to cache Libretro metadata {folder}: {ex.Message}" );
		}

		return text;
	}

	private static FieldIndex ParseFieldIndex( string text, string propertyName, bool quoted )
	{
		var index = new FieldIndex();
		foreach ( var block in EnumerateGameBlocks( text ) )
		{
			var value = quoted ? ExtractQuotedValue( block, propertyName ) : ExtractBareValue( block, propertyName );
			if ( string.IsNullOrWhiteSpace( value ) )
				continue;

			var crc = ExtractCrc( block );
			if ( !string.IsNullOrWhiteSpace( crc ) )
				index.ByCrc[crc] = value;

			var title = Display( ExtractQuotedValue( block, "comment" ), ExtractQuotedValue( block, "name" ) );
			var titleKey = NormalizeTitle( title );
			if ( !string.IsNullOrWhiteSpace( titleKey ) && !index.ByTitle.ContainsKey( titleKey ) )
				index.ByTitle[titleKey] = value;
		}

		return index;
	}

	private static NoIntroIndex ParseNoIntroIndex( string text )
	{
		var index = new NoIntroIndex();
		foreach ( var block in EnumerateGameBlocks( text ) )
		{
			var name = ExtractQuotedValue( block, "name" );
			if ( string.IsNullOrWhiteSpace( name ) )
				continue;

			var entry = new NoIntroEntry(
				name,
				ExtractQuotedValue( block, "region" ),
				ExtractQuotedValue( block, "serial" )
			);

			var crc = ExtractCrc( block );
			if ( !string.IsNullOrWhiteSpace( crc ) )
				index.ByCrc[crc] = entry;

			var titleKey = NormalizeTitle( name );
			if ( !string.IsNullOrWhiteSpace( titleKey ) && !index.ByTitle.ContainsKey( titleKey ) )
				index.ByTitle[titleKey] = entry;
		}

		return index;
	}

	private static NoIntroEntry FindNoIntroEntry( NoIntroIndex index, GameEntry game, string crc )
	{
		if ( !string.IsNullOrWhiteSpace( crc ) && index.ByCrc.TryGetValue( crc, out var entry ) )
			return entry;

		foreach ( var title in CandidateTitles( game, null ) )
		{
			var key = NormalizeTitle( title );
			if ( !string.IsNullOrWhiteSpace( key ) && index.ByTitle.TryGetValue( key, out entry ) )
				return entry;
		}

		return null;
	}

	private static IEnumerable<string> EnumerateGameBlocks( string text )
	{
		if ( string.IsNullOrWhiteSpace( text ) )
			yield break;

		var cursor = 0;
		while ( cursor < text.Length )
		{
			var gameStart = text.IndexOf( "game", cursor, StringComparison.Ordinal );
			if ( gameStart < 0 )
				yield break;

			var openParen = text.IndexOf( '(', gameStart );
			if ( openParen < 0 )
				yield break;

			var depth = 0;
			var inQuote = false;
			for ( var scan = openParen; scan < text.Length; scan++ )
			{
				if ( text[scan] == '"' )
				{
					inQuote = !inQuote;
					continue;
				}

				if ( inQuote )
					continue;

				if ( text[scan] == '(' ) depth++;
				else if ( text[scan] == ')' ) depth--;

				if ( depth == 0 )
				{
					yield return text.Substring( openParen + 1, scan - openParen - 1 );
					cursor = scan + 1;
					break;
				}
			}

			if ( cursor <= openParen )
				yield break;
		}
	}

	private static string ExtractQuotedValue( string block, string propertyName )
	{
		var match = Regex.Match( block, $"\\b{Regex.Escape( propertyName )}\\s+\"([^\"]*)\"", RegexOptions.IgnoreCase );
		return match.Success ? match.Groups[1].Value.Trim() : string.Empty;
	}

	private static string ExtractBareValue( string block, string propertyName )
	{
		var match = Regex.Match( block, $"\\b{Regex.Escape( propertyName )}\\s+([^\\s\\)]+)", RegexOptions.IgnoreCase );
		return match.Success ? match.Groups[1].Value.Trim() : string.Empty;
	}

	private static string ExtractCrc( string block )
	{
		var match = Regex.Match( block, "\\bcrc\\s+([0-9A-Fa-f]{8})", RegexOptions.IgnoreCase );
		return match.Success ? NormalizeCrc( match.Groups[1].Value ) : string.Empty;
	}

	private static IEnumerable<string> CandidateTitles( GameEntry game, NoIntroEntry noIntroEntry )
	{
		if ( !string.IsNullOrWhiteSpace( noIntroEntry?.Name ) ) yield return noIntroEntry.Name;
		if ( !string.IsNullOrWhiteSpace( game?.NoIntroName ) ) yield return game.NoIntroName;
		if ( !string.IsNullOrWhiteSpace( game?.DisplayTitle ) ) yield return game.DisplayTitle;
	}

	private static string NormalizeTitle( string value )
	{
		if ( string.IsNullOrWhiteSpace( value ) )
			return string.Empty;

		return Regex.Replace( value.Trim().ToLowerInvariant(), "\\s+", " " );
	}

	private static string NormalizeCrc( string value )
	{
		return string.IsNullOrWhiteSpace( value ) ? string.Empty : value.Trim().ToUpperInvariant();
	}

	private static string Display( params string[] values )
	{
		foreach ( var value in values )
		{
			if ( !string.IsNullOrWhiteSpace( value ) )
				return value;
		}

		return string.Empty;
	}

	private sealed class FieldIndex
	{
		public Dictionary<string, string> ByCrc { get; } = new( StringComparer.OrdinalIgnoreCase );
		public Dictionary<string, string> ByTitle { get; } = new( StringComparer.OrdinalIgnoreCase );
	}

	private sealed class NoIntroIndex
	{
		public Dictionary<string, NoIntroEntry> ByCrc { get; } = new( StringComparer.OrdinalIgnoreCase );
		public Dictionary<string, NoIntroEntry> ByTitle { get; } = new( StringComparer.OrdinalIgnoreCase );
	}

	private sealed record NoIntroEntry( string Name, string Region, string Serial );
}