UnitTests/TailBoxGeneratorTests.cs
using Sandbox.TailBox;
using System;
using System.IO;
using System.Linq;

[TestClass]
public sealed class TailBoxGeneratorTests
{
	[TestMethod]
	public void ExtractsStaticDynamicAndSafelistedClasses()
	{
		var text = """
			<div class="flex p-4 @(IsActive ? "hover:bg-accent" : "text-accent")"></div>
			<Button class='w-1/2 px-[14px]' />
			@* tailbox safelist: intro:opacity-0 bg-[#0d1418] *@
			""";

		var classes = TailBoxClassExtractor.ExtractClassesFromText( text );

		Assert.IsTrue( classes.Contains( "flex" ) );
		Assert.IsTrue( classes.Contains( "p-4" ) );
		Assert.IsTrue( classes.Contains( "hover:bg-accent" ) );
		Assert.IsTrue( classes.Contains( "text-accent" ) );
		Assert.IsTrue( classes.Contains( "w-1/2" ) );
		Assert.IsTrue( classes.Contains( "px-[14px]" ) );
		Assert.IsTrue( classes.Contains( "intro:opacity-0" ) );
		Assert.IsTrue( classes.Contains( "bg-[#0d1418]" ) );
	}

	[TestMethod]
	public void SavesAndLoadsConfigWithDefaultsMerged()
	{
		var root = CreateTempProject();
		try
		{
			var config = TailBoxEditorProject.SaveDefaultConfig( root );
			config.Colors["brand"] = "#123456";
			config.Safelist.Add( "text-brand" );
			TailBoxEditorProject.SaveConfig( root, config );

			var loaded = TailBoxEditorProject.LoadConfig( root );

			Assert.AreEqual( "Code/tailwand.generated.scss", loaded.OutputPath );
			Assert.AreEqual( "#d7b46a", loaded.Colors["accent"] );
			Assert.AreEqual( "#123456", loaded.Colors["brand"] );
			Assert.IsFalse( loaded.Screens.ContainsKey( "md" ) );
			Assert.AreEqual( "0.15s", loaded.Durations["150"] );
			Assert.AreEqual( "1", loaded.Opacity["100"] );
			Assert.IsTrue( loaded.Safelist.Contains( "text-brand" ) );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void GeneratesRepresentativeUtilities()
	{
		var root = CreateTempProject();
		try
		{
			File.WriteAllText( Path.Combine( root, "Code", "Screen.razor" ), """
				<div class="flex w-full w-1/2 p-4 px-[14px] bg-[#0d1418] text-accent z-10"></div>
				""" );

			var result = TailBoxEditorProject.Generate( root, TailBoxConfig.CreateDefault() );
			var scss = result.GeneratedScss;

			StringAssert.Contains( scss, ".flex {" );
			StringAssert.Contains( scss, "display: flex;" );
			StringAssert.Contains( scss, RuleStart( "w-1/2" ) );
			StringAssert.Contains( scss, "width: 50%;" );
			StringAssert.Contains( scss, ".p-4 {" );
			StringAssert.Contains( scss, "padding: 16px;" );
			StringAssert.Contains( scss, RuleStart( "px-[14px]" ) );
			StringAssert.Contains( scss, "padding-left: 14px;" );
			StringAssert.Contains( scss, "padding-right: 14px;" );
			StringAssert.Contains( scss, RuleStart( "bg-[#0d1418]" ) );
			StringAssert.Contains( scss, "background-color: #0d1418;" );
			StringAssert.Contains( scss, ".text-accent {" );
			StringAssert.Contains( scss, "color: #d7b46a;" );
			StringAssert.Contains( scss, RuleStart( "z-10" ) );
			StringAssert.Contains( scss, "z-index: 10;" );

			Assert.AreEqual( 8, result.GeneratedClassCount );
			Assert.IsTrue( File.Exists( Path.Combine( root, "Code", "tailwand.generated.scss" ) ) );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void EscapesSpecialSelectorCharacters()
	{
		var root = CreateTempProject();
		try
		{
			var config = TailBoxConfig.CreateDefault();
			config.Safelist.AddRange( new[]
			{
				"w-1/2",
				"bg-[#0d1418]",
				"z-[100%]",
				"rounded-[10px]",
				"-mt-2"
			} );

			var result = TailBoxEditorProject.Generate( root, config, writeFile: false );
			var scss = result.GeneratedScss;

			StringAssert.Contains( scss, RuleSelector( "w-1/2" ) );
			StringAssert.Contains( scss, RuleSelector( "bg-[#0d1418]" ) );
			StringAssert.Contains( scss, RuleSelector( "z-[100%]" ) );
			StringAssert.Contains( scss, RuleSelector( "rounded-[10px]" ) );
			StringAssert.Contains( scss, RuleSelector( "-mt-2" ) );
			StringAssert.Contains( scss, "margin-top: -8px;" );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void SkippedItemsCarryStableReasonsAndSourcePaths()
	{
		var root = CreateTempProject();
		try
		{
			var razor = Path.Combine( root, "Code", "Screen.razor" );
			File.WriteAllText( razor, """
				<div class="grid unknown:flex hover:flex first:flex rotate-45 [--brand:#fff]"></div>
				""" );

			var result = TailBoxEditorProject.Generate( root, TailBoxConfig.CreateDefault(), writeFile: false );

			Assert.AreEqual( 0, result.GeneratedClassCount );
			AssertSkip( result, "grid", TailBoxSkipReason.UnsupportedValue, razor );
			AssertSkip( result, "unknown:flex", TailBoxSkipReason.UnsupportedVariant, razor );
			AssertSkip( result, "hover:flex", TailBoxSkipReason.UnsupportedSelectorVariant, razor );
			AssertSkip( result, "first:flex", TailBoxSkipReason.UnsupportedSelectorVariant, razor );
			AssertSkip( result, "rotate-45", TailBoxSkipReason.UnsupportedUtility, razor );
			AssertSkip( result, "[--brand:#fff]", TailBoxSkipReason.UnsupportedArbitraryProperty, razor );
			Assert.AreEqual( result.SkippedClassCount, result.SkippedClasses.Count );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void GeneratesConstructionDashboardExampleDeterministically()
	{
		var repoRoot = FindRepositoryRoot();
		if ( repoRoot is null )
			Assert.Inconclusive( "Repository root could not be located for the ConstructionDashboard fixture." );

		var root = CreateTempProject();
		try
		{
			var exampleRazor = Path.Combine( repoRoot, "Examples", "ConstructionDashboard", "Code", "ConstructionDashboard.razor.example" );
			var exampleConfig = Path.Combine( repoRoot, "Examples", "ConstructionDashboard", TailBoxConfig.FileName );
			Directory.CreateDirectory( Path.Combine( root, "Code" ) );
			File.Copy( exampleRazor, Path.Combine( root, "Code", "ConstructionDashboard.razor" ), overwrite: true );

			var config = TailBoxConfig.LoadJson( File.ReadAllText( exampleConfig ), exampleConfig );
			var first = TailBoxEditorProject.Generate( root, config, writeFile: false );
			var second = TailBoxEditorProject.Generate( root, config, writeFile: false );

			Assert.IsTrue( first.GeneratedClassCount > 35, "Expected the example to generate a broad utility set." );
			Assert.AreEqual( 0, first.SkippedClassCount );
			Assert.AreEqual( first.GeneratedScss, second.GeneratedScss );
			CollectionAssert.AreEqual( first.GeneratedClasses.ToArray(), second.GeneratedClasses.ToArray() );
			StringAssert.Contains( first.GeneratedScss, RuleStart( "w-1/4" ) );
			StringAssert.Contains( first.GeneratedScss, "color: #d7b46a;" );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void SboxStyleParserAcceptsGeneratedEscapedSelectorsWhenAvailable()
	{
		try
		{
			var panelType = Type.GetType( "Sandbox.UI.Panel, Sandbox.Engine", throwOnError: true );
			var panel = Activator.CreateInstance( panelType );
			var styleSheet = panelType.GetProperty( "StyleSheet" )?.GetValue( panel );
			var parse = styleSheet?.GetType().GetMethod( "Parse", new[] { typeof( string ), typeof( bool ) } );
			parse?.Invoke(
				styleSheet,
				new object[]
				{
					RuleStart( "bg-accent" ) + " background-color: #d7b46a; }\n" + RuleStart( "w-1/2" ) + " width: 50%; }",
					false
				} );
		}
		catch ( Exception ex ) when ( ex is NullReferenceException or InvalidOperationException or FileNotFoundException or System.Reflection.TargetInvocationException )
		{
			Assert.Inconclusive( "s&box style parsing is not available in this headless test context: " + ex.Message );
		}
	}

	[TestMethod]
	public void WatcherPathFilterIgnoresGeneratedOutputAndRequiresConfigForOptIn()
	{
		var root = CreateTempProject();
		try
		{
			var output = Path.Combine( root, "Code", "tailwand.generated.scss" );
			var razor = Path.Combine( root, "Code", "Screen.razor" );
			File.WriteAllText( razor, "<div class=\"flex\"></div>" );

			Assert.IsFalse( TailBoxEditorWatcher.IsProjectOptedIn( root ) );
			Assert.IsFalse( TailBoxEditorWatcher.ShouldHandleChangedPath( root, output, output ) );
			Assert.IsTrue( TailBoxEditorWatcher.ShouldHandleChangedPath( root, output, razor ) );

			TailBoxEditorProject.SaveDefaultConfig( root );
			Assert.IsTrue( TailBoxEditorWatcher.IsProjectOptedIn( root ) );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	private static string CreateTempProject()
	{
		return TailBoxTestPaths.CreateTempProject();
	}

	private static void DeleteTempProject( string root )
	{
		if ( Directory.Exists( root ) )
		{
			Directory.Delete( root, true );
		}
	}

	private static void AssertSkip( TailBoxGenerationResult result, string className, TailBoxSkipReason reason, string sourcePath )
	{
		var skipped = result.Skipped.SingleOrDefault( item => item.ClassName == className );
		Assert.IsNotNull( skipped, $"Expected '{className}' to be skipped." );
		Assert.AreEqual( reason, skipped.Reason );
		Assert.AreEqual( Path.GetFullPath( sourcePath ), Path.GetFullPath( skipped.SourcePath ) );
		Assert.IsFalse( string.IsNullOrWhiteSpace( skipped.Detail ) );
	}

	private static string RuleSelector( string className )
	{
		return "." + TailBoxUtilityCompiler.EscapeClassSelector( className );
	}

	private static string RuleStart( string className )
	{
		Assert.IsTrue( TailBoxCandidateParser.TryParse( className, out var candidate, out _ ), $"Expected '{className}' to parse." );
		return RuleSelector( className )
			+ string.Concat( candidate.Variants.Select( variant => variant.SelectorSuffix ) )
			+ " {";
	}

	private static string FindRepositoryRoot()
	{
		foreach ( var start in new[] { Directory.GetCurrentDirectory(), AppContext.BaseDirectory } )
		{
			var current = new DirectoryInfo( start );
			while ( current is not null )
			{
				var fixture = Path.Combine( current.FullName, "Examples", "ConstructionDashboard", "Code", "ConstructionDashboard.razor.example" );
				if ( File.Exists( fixture ) )
					return current.FullName;

				current = current.Parent;
			}
		}

		return null;
	}
}