Editor/Converters/FabModelConverter.cs
using System;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;

namespace FabBridge.Converters;

/// <summary>
/// Converts Fab mesh files (FBX) to s&box VMDL format, bundling LODs into a single VMDL.
/// </summary>
public static class FabModelConverter
{
	/// <summary>
	/// Fab/Megascans quality tier folder names in descending detail order. "raw" is deliberately
	/// excluded — that folder holds the source asset (often a ZBrush .ztl), not a LOD-friendly mesh.
	/// Tiers below the primary mesh's tier are promoted to LOD1, LOD2, ...
	/// </summary>
	private static readonly string[][] QualityTierOrder =
	{
		new[] { "high" },
		new[] { "medium", "mid" },
		new[] { "low" },
	};

	/// <summary>
	/// Result of a model conversion
	/// </summary>
	public class ConversionResult
	{
		public bool Success { get; set; }
		public string SourcePath { get; set; }
		public string DestinationPath { get; set; }
		public string VmdlPath { get; set; }
		public string RelativePath { get; set; }
		public string Error { get; set; }
		public Asset Asset { get; set; }
		public int LodCount { get; set; }
	}

	/// <summary>
	/// Convert a Fab mesh (no LODs) to s&box VMDL format
	/// </summary>
	public static ConversionResult Convert( FabMesh fabMesh, string assetBaseName, string destinationFolder, bool generateCollisions = false )
	{
		return ConvertWithLods( new List<string> { fabMesh.GetFilePath() }, assetBaseName, destinationFolder, generateCollisions );
	}

	/// <summary>
	/// Convert a mesh from a file path (single LOD)
	/// </summary>
	public static ConversionResult ConvertFromPath( string sourcePath, string assetBaseName, string destinationFolder, bool generateCollisions = false )
	{
		return ConvertWithLods( new List<string> { sourcePath }, assetBaseName, destinationFolder, generateCollisions );
	}

	/// <summary>
	/// Convert a mesh and its LODs into a single VMDL asset.
	/// </summary>
	/// <param name="lodSourcePaths">Ordered source FBX paths. Index 0 is LOD0 (primary).</param>
	/// <param name="assetBaseName">Base name for the resulting VMDL/FBX files.</param>
	/// <param name="destinationFolder">Absolute path to destination folder (typically models/).</param>
	/// <param name="generateCollisions">If true, append a PhysicsHullFromRender block so the engine auto-generates convex-hull collision.</param>
	public static ConversionResult ConvertWithLods( List<string> lodSourcePaths, string assetBaseName, string destinationFolder, bool generateCollisions = false )
	{
		var result = new ConversionResult();

		try
		{
			if ( lodSourcePaths == null || lodSourcePaths.Count == 0 )
			{
				result.Error = "No source paths provided";
				return result;
			}

			// Filter out missing/empty entries while preserving order
			var validSources = lodSourcePaths
				.Where( p => !string.IsNullOrEmpty( p ) && File.Exists( p ) )
				.ToList();

			if ( validSources.Count == 0 )
			{
				result.Error = $"None of the source files exist (primary: {lodSourcePaths[0]})";
				return result;
			}

			result.SourcePath = validSources[0];
			Directory.CreateDirectory( destinationFolder );

			// Copy each LOD source into the destination folder under a predictable name.
			var copiedPaths = new List<string>();
			for ( int i = 0; i < validSources.Count; i++ )
			{
				var src = validSources[i];
				var ext = Path.GetExtension( src );
				var destName = validSources.Count > 1 ? $"{assetBaseName}_lod{i}{ext}" : $"{assetBaseName}{ext}";
				var dest = Path.Combine( destinationFolder, destName );
				File.Copy( src, dest, overwrite: true );
				copiedPaths.Add( dest );
				Log.Info( $"FabBridge: Copied LOD{i} mesh to {dest}" );
			}

			result.DestinationPath = copiedPaths[0];

			// Register each mesh asset so the asset system knows about them before the VMDL
			// references them by relative path. Skipping this leaves the VMDL compile chasing
			// unregistered dependencies.
			var meshAssets = new List<Asset>();
			foreach ( var p in copiedPaths )
			{
				var a = AssetSystem.RegisterFile( p ) ?? AssetSystem.FindByPath( p );
				if ( a == null )
					Log.Warning( $"FabBridge: Failed to register mesh asset {p}" );
				meshAssets.Add( a );
			}

			if ( meshAssets[0] == null )
			{
				result.Error = "Failed to register primary mesh asset";
				return result;
			}

			// Bootstrap the VMDL from LOD0 — this gives us correct import_scale/rotation/units
			// detection that we'd otherwise have to guess.
			var vmdlPath = Path.Combine( destinationFolder, $"{assetBaseName}.vmdl" );
			result.VmdlPath = vmdlPath;

			Asset vmdlAsset = EditorUtility.CreateModelFromMeshFile( meshAssets[0], vmdlPath );
			if ( vmdlAsset == null )
			{
				// File already existed — pick it up via the asset system.
				vmdlAsset = AssetSystem.FindByPath( vmdlPath );
			}

			if ( vmdlAsset == null || !File.Exists( vmdlPath ) )
			{
				result.Error = "Failed to create VMDL bootstrap";
				return result;
			}

			result.Asset = vmdlAsset;
			result.RelativePath = vmdlAsset.Path;
			result.LodCount = copiedPaths.Count;

			// Collect project-relative paths for any additional LODs that registered cleanly.
			var lodRelativePaths = new List<string>();
			for ( int i = 0; i < meshAssets.Count; i++ )
			{
				var a = meshAssets[i];
				if ( a == null )
				{
					Log.Warning( $"FabBridge: Skipping LOD{i} from VMDL — asset not registered" );
					continue;
				}
				lodRelativePaths.Add( a.Path.Replace( '\\', '/' ) );
			}

			// Apply post-bootstrap modifications: LOD expansion (multi-LOD only) and collision injection.
			var vmdlText = File.ReadAllText( vmdlPath );
			var modified = false;

			if ( lodRelativePaths.Count >= 2 )
			{
				var rewritten = AddLodsToVmdl( vmdlText, lodRelativePaths );
				if ( rewritten != null )
				{
					vmdlText = rewritten;
					modified = true;
					Log.Info( $"FabBridge: Expanded VMDL to {lodRelativePaths.Count} LOD level(s)" );
				}
				else
				{
					Log.Warning( "FabBridge: LOD rewrite produced no changes; VMDL kept as single-LOD" );
				}
			}

			if ( generateCollisions && !VmdlHasPhysicsShape( vmdlText ) )
			{
				vmdlText = AddPhysicsShapeList( vmdlText );
				modified = true;
				Log.Info( "FabBridge: Added PhysicsHullFromRender for engine-generated collision" );
			}

			if ( modified )
			{
				File.WriteAllText( vmdlPath, vmdlText );

				// Drop stale compiled output so the asset system picks up our new source.
				var compiledPath = vmdlPath + "_c";
				if ( File.Exists( compiledPath ) )
				{
					try { File.Delete( compiledPath ); }
					catch ( Exception delEx ) { Log.Warning( $"FabBridge: Couldn't delete stale {compiledPath}: {delEx.Message}" ); }
				}
			}

			result.Success = true;
		}
		catch ( Exception ex )
		{
			result.Error = ex.Message;
			Log.Error( $"FabBridge: Model conversion failed: {ex.Message}" );
			Log.Error( $"FabBridge: Stack trace: {ex.StackTrace}" );
		}

		return result;
	}

	/// <summary>
	/// Convert all meshes from a Fab asset, bundling each mesh's LODs into a single VMDL.
	/// </summary>
	public static List<ConversionResult> ConvertAllMeshes( FabAsset fabAsset, string destinationFolder, bool generateCollisions = false, bool detectLods = true )
	{
		var results = new List<ConversionResult>();
		var baseName = fabAsset.GetSafeFileName();

		var allMeshes = fabAsset.GetAllMeshes();
		Log.Info( $"FabModelConverter: Found {allMeshes.Count} mesh entries" );

		// If a single mesh has no per-mesh Lods, fall back to the asset-level LodList. This
		// matches older Quixel Bridge exports that put LODs in a top-level array.
		var useFallbackLodList = allMeshes.Count == 1 && fabAsset.LodList?.Count > 0;

		for ( int i = 0; i < allMeshes.Count; i++ )
		{
			var mesh = allMeshes[i];
			var lodPaths = mesh.GetLodFilePaths( useFallbackLodList ? fabAsset.LodList : null );

			// Fab downloads quality tiers as independent files in sibling "high"/"medium"/"low" folders.
			// If the JSON didn't already provide a LOD chain, see if those tiers exist on disk next to
			// the primary mesh and promote them to additional LOD slots. Disabled when the user has
			// turned off LOD detection in the widget.
			if ( detectLods && lodPaths.Count == 1 )
			{
				var siblingLods = DiscoverQualityTierLods( lodPaths[0] );
				if ( siblingLods.Count > lodPaths.Count )
				{
					Log.Info( $"FabModelConverter: Found {siblingLods.Count - 1} sibling quality tier(s) to use as LODs" );
					lodPaths = siblingLods;
				}
			}

			Log.Info( $"FabModelConverter: Mesh {i} has {lodPaths.Count} LOD level(s)" );
			for ( int li = 0; li < lodPaths.Count; li++ )
				Log.Info( $"  LOD{li}: {lodPaths[li]}" );

			if ( lodPaths.Count == 0 )
			{
				Log.Warning( $"FabModelConverter: Mesh {i} has no file path" );
				continue;
			}

			var meshName = allMeshes.Count > 1 ? $"{baseName}_{i}" : baseName;
			var result = ConvertWithLods( lodPaths, meshName, destinationFolder, generateCollisions );
			results.Add( result );
		}

		// Handle components that happen to be mesh files (uncommon — Components are usually textures).
		if ( fabAsset.Components != null )
		{
			foreach ( var component in fabAsset.Components )
			{
				if ( IsMeshFile( component.Path ) )
				{
					var componentName = !string.IsNullOrEmpty( component.Name )
						? $"{baseName}_{component.Name.ToLowerInvariant().Replace( " ", "_" )}"
						: baseName;
					var result = ConvertWithLods( new List<string> { component.Path }, componentName, destinationFolder, generateCollisions );
					results.Add( result );
				}
			}
		}

		return results;
	}

	/// <summary>
	/// Check if a file path is a mesh file
	/// </summary>
	private static bool IsMeshFile( string path )
	{
		if ( string.IsNullOrEmpty( path ) )
			return false;

		var ext = Path.GetExtension( path )?.ToLowerInvariant();
		return ext == ".fbx" || ext == ".obj" || ext == ".gltf" || ext == ".glb" || ext == ".dae";
	}

	/// <summary>
	/// Set the default material in a VMDL file
	/// </summary>
	public static bool SetDefaultMaterial( string vmdlPath, string materialPath )
	{
		try
		{
			if ( !File.Exists( vmdlPath ) )
			{
				Log.Warning( $"FabModelConverter: VMDL file not found: {vmdlPath}" );
				return false;
			}

			var content = File.ReadAllText( vmdlPath );

			var materialPattern = new Regex( @"global_default_material\s*=\s*""[^""]*""" );

			if ( materialPattern.IsMatch( content ) )
			{
				content = materialPattern.Replace( content, $"global_default_material = \"{materialPath}\"" );
				Log.Info( $"FabModelConverter: Updated global_default_material to {materialPath}" );
			}
			else
			{
				Log.Warning( $"FabModelConverter: No global_default_material found in VMDL, cannot set material" );
				return false;
			}

			File.WriteAllText( vmdlPath, content );
			Log.Info( $"FabModelConverter: Updated VMDL file, asset system will recompile automatically" );

			return true;
		}
		catch ( Exception ex )
		{
			Log.Error( $"FabModelConverter: Failed to set default material: {ex.Message}" );
			return false;
		}
	}

	/// <summary>
	/// Set the default material for a conversion result
	/// </summary>
	public static bool SetDefaultMaterial( ConversionResult modelResult, FabMaterialConverter.ConversionResult materialResult )
	{
		if ( modelResult == null || !modelResult.Success || string.IsNullOrEmpty( modelResult.VmdlPath ) )
		{
			Log.Warning( "FabModelConverter: Invalid model result, cannot set material" );
			return false;
		}

		if ( materialResult == null || !materialResult.Success || string.IsNullOrEmpty( materialResult.RelativePath ) )
		{
			Log.Warning( "FabModelConverter: Invalid material result, cannot set material" );
			return false;
		}

		var normalizedMaterialPath = materialResult.RelativePath?.Replace( '\\', '/' );
		return SetDefaultMaterial( modelResult.VmdlPath, normalizedMaterialPath );
	}

	// -----------------------------------------------------------------------
	// VMDL LOD post-processing
	// -----------------------------------------------------------------------

	/// <summary>
	/// Adds LODGroupList and additional RenderMeshFile entries to a bootstrap VMDL.
	/// Returns null if the input doesn't look like a single-mesh VMDL we can patch.
	/// </summary>
	private static string AddLodsToVmdl( string vmdlText, List<string> lodFilenames )
	{
		if ( string.IsNullOrEmpty( vmdlText ) || lodFilenames == null || lodFilenames.Count < 2 )
			return null;

		// Modern bootstrap (modeldoc30) writes its own auto-simplify LODGroupList. Strip any
		// existing LODGroupList blocks so we don't end up with duplicates fighting our explicit chain.
		vmdlText = RemoveBlocksByClass( vmdlText, "LODGroupList" );

		// Locate the existing RenderMeshFile block (the one CreateModelFromMeshFile produced).
		var rmFileMatch = Regex.Match( vmdlText, @"_class\s*=\s*""RenderMeshFile""" );
		if ( !rmFileMatch.Success )
		{
			Log.Warning( "FabBridge: AddLodsToVmdl - no RenderMeshFile found in bootstrap VMDL" );
			return null;
		}

		// The block-opening '{' lives just before the _class line.
		int openBrace = vmdlText.LastIndexOf( '{', rmFileMatch.Index );
		if ( openBrace < 0 )
			return null;

		int closeBrace = FindMatchingBrace( vmdlText, openBrace );
		if ( closeBrace < 0 )
			return null;

		var originalBlock = vmdlText.Substring( openBrace, closeBrace - openBrace + 1 );

		// LOD0 keeps the bootstrap's import_filter — it was derived from LOD0's FBX content.
		var lod0Block = EnsureNamedRenderMeshFile( originalBlock, "LOD0", lodFilenames[0] );

		var sb = new StringBuilder();
		sb.Append( lod0Block );
		for ( int i = 1; i < lodFilenames.Count; i++ )
		{
			// The bootstrap's filter names a mesh that exists only inside LOD0's FBX. Each lower-tier
			// LOD FBX contains a differently-named mesh, so we must reset to a permissive filter or
			// nothing gets imported and the LOD renders empty (invisible at switch distance).
			var lodBlock = EnsureNamedRenderMeshFile( originalBlock, $"LOD{i}", lodFilenames[i] );
			lodBlock = ResetImportFilter( lodBlock );
			sb.Append( ",\n\t\t\t\t" );
			sb.Append( lodBlock );
		}

		var withExtraMeshes = vmdlText.Substring( 0, openBrace ) + sb.ToString() + vmdlText.Substring( closeBrace + 1 );

		// Insert LODGroupList as the first child of rootNode.
		var rootChildrenMatch = Regex.Match( withExtraMeshes, @"_class\s*=\s*""RootNode""[\s\S]*?children\s*=\s*\[" );
		if ( !rootChildrenMatch.Success )
		{
			Log.Warning( "FabBridge: AddLodsToVmdl - couldn't find rootNode children to insert LODGroupList" );
			return withExtraMeshes; // Better to ship a multi-mesh VMDL than nothing.
		}

		int insertPos = rootChildrenMatch.Index + rootChildrenMatch.Length;
		var lodGroupList = BuildLodGroupList( lodFilenames.Count );

		return withExtraMeshes.Substring( 0, insertPos )
			+ "\n\t\t\t" + lodGroupList + ","
			+ withExtraMeshes.Substring( insertPos );
	}

	/// <summary>
	/// Returns a copy of a RenderMeshFile block with `name` and `filename` set to the given values.
	/// </summary>
	private static string EnsureNamedRenderMeshFile( string block, string lodName, string filename )
	{
		var result = block;

		// Replace or insert `name = "..."`
		if ( Regex.IsMatch( result, @"\bname\s*=\s*""[^""]*""" ) )
		{
			result = Regex.Replace( result, @"\bname\s*=\s*""[^""]*""", $"name = \"{lodName}\"" );
		}
		else
		{
			// Insert right after the _class line.
			result = Regex.Replace(
				result,
				@"(_class\s*=\s*""RenderMeshFile""[^\r\n]*\r?\n)",
				m => m.Value + "\t\t\t\t\tname = \"" + lodName + "\"\n"
			);
		}

		// Replace `filename = "..."` with our project-relative path.
		result = Regex.Replace( result, @"\bfilename\s*=\s*""[^""]*""", $"filename = \"{filename}\"" );

		return result;
	}

	/// <summary>
	/// Replace a RenderMeshFile block's import_filter with permissive defaults so every mesh in
	/// the referenced FBX is imported. Used when cloning the bootstrap block for non-LOD0 levels.
	/// </summary>
	private static string ResetImportFilter( string block )
	{
		var pattern = new Regex( @"import_filter\s*=\s*\{[^}]*\}", RegexOptions.Singleline );
		const string permissive = "import_filter = \n\t\t\t\t\t\t{\n\t\t\t\t\t\t\texclude_by_default = false\n\t\t\t\t\t\t\texception_list = [  ]\n\t\t\t\t\t\t}";

		if ( pattern.IsMatch( block ) )
			return pattern.Replace( block, permissive );

		// No filter present at all — leave the block as-is (engine defaults are already permissive).
		return block;
	}

	/// <summary>
	/// Remove every top-level KV3 node whose `_class` equals <paramref name="className"/>, along
	/// with its trailing comma and surrounding indentation, so the children array stays well-formed.
	/// </summary>
	private static string RemoveBlocksByClass( string vmdlText, string className )
	{
		var pattern = new Regex( $@"_class\s*=\s*""{Regex.Escape( className )}""" );

		while ( true )
		{
			var match = pattern.Match( vmdlText );
			if ( !match.Success )
				break;

			int openIdx = vmdlText.LastIndexOf( '{', match.Index );
			if ( openIdx < 0 )
				break;

			int closeIdx = FindMatchingBrace( vmdlText, openIdx );
			if ( closeIdx < 0 )
				break;

			// Eat any leading indentation on the same line so we don't leave dangling tabs.
			int start = openIdx;
			while ( start > 0 && (vmdlText[start - 1] == '\t' || vmdlText[start - 1] == ' ') )
				start--;

			int end = closeIdx + 1;
			if ( end < vmdlText.Length && vmdlText[end] == ',' )
				end++;
			while ( end < vmdlText.Length && (vmdlText[end] == ' ' || vmdlText[end] == '\t') )
				end++;
			if ( end < vmdlText.Length && vmdlText[end] == '\r' )
				end++;
			if ( end < vmdlText.Length && vmdlText[end] == '\n' )
				end++;

			vmdlText = vmdlText.Substring( 0, start ) + vmdlText.Substring( end );
		}

		return vmdlText;
	}

	/// <summary>
	/// Build the KV3 text for an LODGroupList node with one LODGroup per LOD level.
	/// </summary>
	private static string BuildLodGroupList( int lodCount )
	{
		var sb = new StringBuilder();
		sb.Append( "{\n" );
		sb.Append( "\t\t\t\t_class = \"LODGroupList\"\n" );
		sb.Append( "\t\t\t\tchildren = \n" );
		sb.Append( "\t\t\t\t[\n" );

		for ( int i = 0; i < lodCount; i++ )
		{
			var threshold = FabLodSettings.GetThreshold( i );

			sb.Append( "\t\t\t\t\t{\n" );
			sb.Append( "\t\t\t\t\t\t_class = \"LODGroup\"\n" );
			sb.Append( "\t\t\t\t\t\tswitch_threshold = " );
			sb.Append( threshold.ToString( "F1", CultureInfo.InvariantCulture ) );
			sb.Append( "\n" );
			sb.Append( "\t\t\t\t\t\tmeshes = \n" );
			sb.Append( "\t\t\t\t\t\t[\n" );
			sb.Append( "\t\t\t\t\t\t\t\"LOD" );
			sb.Append( i );
			sb.Append( "\",\n" );
			sb.Append( "\t\t\t\t\t\t]\n" );
			sb.Append( "\t\t\t\t\t},\n" );
		}

		sb.Append( "\t\t\t\t]\n" );
		sb.Append( "\t\t\t}" );
		return sb.ToString();
	}

	/// <summary>
	/// True if the VMDL already declares a PhysicsShapeList — avoids stacking duplicate physics nodes
	/// if the bootstrap (or a previous import pass) already inserted one.
	/// </summary>
	private static bool VmdlHasPhysicsShape( string vmdlText )
	{
		return Regex.IsMatch( vmdlText, @"_class\s*=\s*""PhysicsShapeList""" );
	}

	/// <summary>
	/// Append a PhysicsShapeList with a single PhysicsHullFromRender child to the rootNode's children
	/// array. This is the engine-native way to auto-generate convex-hull collision from the render mesh.
	/// </summary>
	private static string AddPhysicsShapeList( string vmdlText )
	{
		// Find the rootNode children array opener so we can append before the closing `]`.
		var rootChildrenMatch = Regex.Match( vmdlText, @"_class\s*=\s*""RootNode""[\s\S]*?children\s*=\s*\[" );
		if ( !rootChildrenMatch.Success )
		{
			Log.Warning( "FabBridge: AddPhysicsShapeList - couldn't find rootNode children array" );
			return vmdlText;
		}

		int arrayOpen = rootChildrenMatch.Index + rootChildrenMatch.Length;
		int arrayClose = FindMatchingBracket( vmdlText, arrayOpen - 1 );
		if ( arrayClose < 0 )
		{
			Log.Warning( "FabBridge: AddPhysicsShapeList - couldn't locate end of children array" );
			return vmdlText;
		}

		const string physicsBlock =
			"{\n" +
			"\t\t\t\t_class = \"PhysicsShapeList\"\n" +
			"\t\t\t\tchildren = \n" +
			"\t\t\t\t[\n" +
			"\t\t\t\t\t{\n" +
			"\t\t\t\t\t\t_class = \"PhysicsHullFromRender\"\n" +
			"\t\t\t\t\t\tparent_bone = \"\"\n" +
			"\t\t\t\t\t\tsurface_prop = \"default\"\n" +
			"\t\t\t\t\t\tcollision_tags = \"solid\"\n" +
			"\t\t\t\t\t\tfaceMergeAngle = 20.0\n" +
			"\t\t\t\t\t\tmaxHullVertices = 32\n" +
			"\t\t\t\t\t},\n" +
			"\t\t\t\t]\n" +
			"\t\t\t}";

		// Insert just before the closing `]`. The preceding child node always ends with a trailing
		// comma in our output, so we don't need to inject one.
		return vmdlText.Substring( 0, arrayClose )
			+ "\t\t\t" + physicsBlock + ",\n\t\t\t"
			+ vmdlText.Substring( arrayClose );
	}

	/// <summary>
	/// Find the matching `]` for a `[` at <paramref name="openIdx"/>, honoring strings and nested braces.
	/// </summary>
	private static int FindMatchingBracket( string text, int openIdx )
	{
		int depth = 0;
		bool inString = false;
		bool escaped = false;

		for ( int i = openIdx; i < text.Length; i++ )
		{
			char c = text[i];

			if ( escaped ) { escaped = false; continue; }
			if ( c == '\\' && inString ) { escaped = true; continue; }
			if ( c == '"' ) { inString = !inString; continue; }
			if ( inString ) continue;

			if ( c == '[' )
			{
				depth++;
			}
			else if ( c == ']' )
			{
				depth--;
				if ( depth == 0 )
					return i;
			}
		}

		return -1;
	}

	/// <summary>
	/// Detect Fab quality-tier folders on disk and return an ordered LOD chain.
	/// The primary mesh is always kept as LOD0. Lower-detail tiers (relative to the primary's
	/// tier) that exist on disk are appended as LOD1, LOD2, ...
	/// </summary>
	/// <remarks>
	/// Fab's UI requires each quality tier to be downloaded separately, leaving a cache layout like:
	///   <c>VaultCache/.../fbx/high/&lt;extracted&gt;/Asset_High.fbx</c>
	///   <c>VaultCache/.../fbx/medium/&lt;extracted&gt;/Asset_Medium.fbx</c>
	///   <c>VaultCache/.../fbx/low/&lt;extracted&gt;/Asset_Low.fbx</c>
	/// Any subset may exist. If only medium+low are present, medium becomes LOD0 and low becomes LOD1.
	/// "raw" is intentionally skipped — it's source data (e.g. ZBrush .ztl), not a usable LOD.
	/// </remarks>
	private static List<string> DiscoverQualityTierLods( string primaryMeshPath )
	{
		var result = new List<string>();
		if ( string.IsNullOrEmpty( primaryMeshPath ) )
			return result;

		result.Add( primaryMeshPath );

		// Walk up from the mesh file looking for a folder whose name matches a quality tier.
		string current = Path.GetDirectoryName( primaryMeshPath );
		int primaryTierIdx = -1;
		string tierParent = null;

		while ( !string.IsNullOrEmpty( current ) )
		{
			var name = Path.GetFileName( current )?.ToLowerInvariant();
			if ( !string.IsNullOrEmpty( name ) )
			{
				for ( int gi = 0; gi < QualityTierOrder.Length; gi++ )
				{
					if ( Array.IndexOf( QualityTierOrder[gi], name ) >= 0 )
					{
						primaryTierIdx = gi;
						tierParent = Path.GetDirectoryName( current );
						break;
					}
				}
				if ( primaryTierIdx >= 0 )
					break;
			}
			current = Path.GetDirectoryName( current );
		}

		if ( primaryTierIdx < 0 || string.IsNullOrEmpty( tierParent ) || !Directory.Exists( tierParent ) )
			return result;

		// Only append tiers BELOW the primary in detail order — never replace the primary or add higher tiers.
		for ( int i = primaryTierIdx + 1; i < QualityTierOrder.Length; i++ )
		{
			foreach ( var synonym in QualityTierOrder[i] )
			{
				var tierDir = Path.Combine( tierParent, synonym );
				if ( !Directory.Exists( tierDir ) )
					continue;

				var meshFile = FindFirstMeshFile( tierDir );
				if ( !string.IsNullOrEmpty( meshFile ) )
				{
					Log.Info( $"FabModelConverter: Discovered '{synonym}' tier mesh: {meshFile}" );
					result.Add( meshFile );
					break; // synonyms — first match in the group wins.
				}
			}
		}

		return result;
	}

	/// <summary>
	/// Find the first supported mesh file (FBX preferred, then OBJ/glTF/DAE) under <paramref name="folder"/>.
	/// </summary>
	private static string FindFirstMeshFile( string folder )
	{
		if ( !Directory.Exists( folder ) )
			return null;

		foreach ( var pattern in new[] { "*.fbx", "*.obj", "*.gltf", "*.glb", "*.dae" } )
		{
			var file = Directory.EnumerateFiles( folder, pattern, SearchOption.AllDirectories ).FirstOrDefault();
			if ( !string.IsNullOrEmpty( file ) )
				return file;
		}

		return null;
	}

	/// <summary>
	/// Find the matching closing brace for the '{' at <paramref name="openIdx"/>, honoring strings.
	/// </summary>
	private static int FindMatchingBrace( string text, int openIdx )
	{
		int depth = 0;
		bool inString = false;
		bool escaped = false;

		for ( int i = openIdx; i < text.Length; i++ )
		{
			char c = text[i];

			if ( escaped ) { escaped = false; continue; }

			if ( c == '\\' && inString ) { escaped = true; continue; }

			if ( c == '"' ) { inString = !inString; continue; }

			if ( inString )
				continue;

			if ( c == '{' )
			{
				depth++;
			}
			else if ( c == '}' )
			{
				depth--;
				if ( depth == 0 )
					return i;
			}
		}

		return -1;
	}
}