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

[TestClass]
public sealed class TailBoxBehaviorTests
{
	[TestMethod]
	public void ExtractorReadsComponentClassAttributesAndConditionalStringLiterals()
	{
		var text = """
			<Panel Class="flex items-center">
				<Button class=@(IsReady ? "bg-good text-white" : "bg-danger text-muted") />
				<div class='px-4 py-2'></div>
			</Panel>
			""";

		var classes = TailBoxClassExtractor.ExtractClassesFromText( text );

		AssertContainsAll( classes, "flex", "items-center", "bg-good", "text-white", "bg-danger", "text-muted", "px-4", "py-2" );
	}

	[TestMethod]
	public void ExtractorReadsSafelistCommentsInRazorAndHtml()
	{
		var text = """
			@* tailbox safelist: intro:opacity-0 bg-[#0d1418] *@
			<!-- tailbox safelist: hover:bg-accent active:bg-panel -->
			""";

		var classes = TailBoxClassExtractor.ExtractClassesFromText( text );

		AssertContainsAll( classes, "intro:opacity-0", "bg-[#0d1418]", "hover:bg-accent", "active:bg-panel" );
	}

	[TestMethod]
	public void ExtractorCleansPunctuationAndIgnoresRazorExpressions()
	{
		var text = """
			<div class="flex, p-4; @ComputedClass"></div>
			<span class="hover:bg-accent) (text-muted"></span>
			""";

		var classes = TailBoxClassExtractor.ExtractClassesFromText( text );

		AssertContainsAll( classes, "flex", "p-4", "hover:bg-accent", "text-muted" );
		Assert.IsFalse( classes.Contains( "@ComputedClass" ) );
	}

	[TestMethod]
	public void ExtractorIgnoresPlainProseInBroadStringLiterals()
	{
		var text = """
			<div class="transition"></div>
			@code {
				private const string Copy = "transform transition.";
				private const string Conditional = IsReady ? "flex" : "bg-good";
			}
			""";

		var classes = TailBoxClassExtractor.ExtractClassesFromText( text );

		Assert.IsTrue( classes.Contains( "transition" ) );
		Assert.IsTrue( classes.Contains( "flex" ) );
		Assert.IsTrue( classes.Contains( "bg-good" ) );
		Assert.IsFalse( classes.Contains( "transform" ) );
	}

	[TestMethod]
	public void GeneratorUsesConfigSafelistAndDeduplicatesClasses()
	{
		var root = CreateTempProject();
		try
		{
			WriteFile( root, "Code/Screen.razor", "<div class=\"flex flex\"></div>" );
			var config = TailBoxConfig.CreateDefault();
			config.Safelist.Add( "flex p-4, text-accent" );

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

			Assert.AreEqual( 3, result.GeneratedClassCount );
			AssertContainsAll( result.GeneratedClasses, "flex", "p-4", "text-accent" );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void GeneratorWritesOnlyWhenOutputChanges()
	{
		var root = CreateTempProject();
		try
		{
			WriteFile( root, "Code/Screen.razor", "<div class=\"flex\"></div>" );
			var generator = new TailBoxGenerator();
			var config = TailBoxConfig.CreateDefault();

			var first = TailBoxEditorProject.Generate( root, config );
			var second = TailBoxEditorProject.Generate( root, config );
			config.Safelist.Add( "p-4" );
			var third = TailBoxEditorProject.Generate( root, config );

			Assert.IsTrue( first.WroteFile );
			Assert.IsFalse( second.WroteFile );
			Assert.IsTrue( third.WroteFile );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void GeneratorHonorsCustomContentGlobs()
	{
		var root = CreateTempProject();
		try
		{
			WriteFile( root, "Code/Screen.razor", "<div class=\"flex\"></div>" );
			WriteFile( root, "Ui/Nested/Screen.razor", "<div class=\"p-4\"></div>" );
			var config = TailBoxConfig.CreateDefault();
			config.Content.Clear();
			config.Content.Add( "Ui/**/*.razor" );

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

			Assert.AreEqual( 1, result.ScannedFileCount );
			AssertContainsAll( result.GeneratedClasses, "p-4" );
			Assert.IsFalse( result.GeneratedClasses.Contains( "flex" ) );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void GeneratorIgnoresGeneratedOutputAndBuildFoldersEvenWithBroadGlobs()
	{
		var root = CreateTempProject();
		try
		{
			WriteFile( root, "Code/Screen.razor", "<div class=\"flex\"></div>" );
			WriteFile( root, "Code/tailwand.generated.scss", "\"text-danger\"" );
			WriteFile( root, "Code/bin/Bogus.razor", "<div class=\"p-4\"></div>" );
			WriteFile( root, "Code/obj/Bogus.razor", "<div class=\"px-4\"></div>" );
			WriteFile( root, ".sbox/Bogus.razor", "<div class=\"py-4\"></div>" );
			var config = TailBoxConfig.CreateDefault();
			config.Content.Clear();
			config.Content.Add( "**/*" );

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

			Assert.AreEqual( 1, result.GeneratedClassCount );
			AssertContainsAll( result.GeneratedClasses, "flex" );
			Assert.IsFalse( result.GeneratedClasses.Contains( "text-danger" ) );
			Assert.IsFalse( result.GeneratedClasses.Contains( "p-4" ) );
			Assert.IsFalse( result.GeneratedClasses.Contains( "px-4" ) );
			Assert.IsFalse( result.GeneratedClasses.Contains( "py-4" ) );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void GeneratorWritesCustomOutputPath()
	{
		var root = CreateTempProject();
		try
		{
			var config = TailBoxConfig.CreateDefault();
			config.OutputPath = "Assets/Generated/tailbox.scss";
			config.Safelist.Add( "flex" );

			var result = TailBoxEditorProject.Generate( root, config );

			Assert.IsTrue( result.WroteFile );
			Assert.IsTrue( File.Exists( Path.Combine( root, "Assets", "Generated", "tailbox.scss" ) ) );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

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

			var json = File.ReadAllText( TailBoxEditorProject.GetConfigPath( root ) );
			var loaded = TailBoxEditorProject.LoadConfig( root );

			StringAssert.Contains( json, "\"outputPath\"" );
			StringAssert.Contains( json, "\"fontSizes\"" );
			Assert.AreEqual( "#123456", loaded.Colors["brand"] );
			Assert.AreEqual( "#d7b46a", loaded.Colors["accent"] );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void ConfigLoadAcceptsCamelCaseOverrides()
	{
		var root = CreateTempProject();
		try
		{
			WriteFile( root, TailBoxConfig.FileName, """
				{
				  "outputPath": "Code/generated/custom.scss",
				  "content": [ "Ui/**/*.razor" ],
				  "safelist": [ "text-brand" ],
				  "colors": {
				    "brand": "#123456"
				  }
				}
				""" );

			var loaded = TailBoxEditorProject.LoadConfig( root );

			Assert.AreEqual( "Code/generated/custom.scss", loaded.OutputPath );
			Assert.AreEqual( "Ui/**/*.razor", loaded.Content.Single() );
			Assert.AreEqual( "#123456", loaded.Colors["brand"] );
			Assert.AreEqual( "#d7b46a", loaded.Colors["accent"] );
			AssertContainsAll( loaded.Safelist, "text-brand" );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void ConfigResolvesRelativeAndAbsoluteOutputPaths()
	{
		var root = CreateTempProject();
		try
		{
			var config = TailBoxConfig.CreateDefault();
			Assert.AreEqual(
				Path.GetFullPath( Path.Combine( root, "Code", "tailwand.generated.scss" ) ),
				TailBoxEditorProject.ResolveOutputPath( root, config ) );

			var absolute = Path.Combine( root, "Custom", "tailbox.scss" );
			config.OutputPath = absolute;
			Assert.AreEqual( Path.GetFullPath( absolute ), TailBoxEditorProject.ResolveOutputPath( root, config ) );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void WatcherHandlesConfigAndRazorOnly()
	{
		var root = CreateTempProject();
		try
		{
			var output = Path.Combine( root, "Code", "tailwand.generated.scss" );

			Assert.IsTrue( TailBoxEditorWatcher.ShouldHandleChangedPath( root, output, Path.Combine( root, TailBoxConfig.FileName ) ) );
			Assert.IsTrue( TailBoxEditorWatcher.ShouldHandleChangedPath( root, output, Path.Combine( root, "Code", "Screen.razor" ) ) );
			Assert.IsFalse( TailBoxEditorWatcher.ShouldHandleChangedPath( root, output, Path.Combine( root, "Code", "Screen.razor.scss" ) ) );
			Assert.IsFalse( TailBoxEditorWatcher.ShouldHandleChangedPath( root, output, output ) );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void WatcherIgnoresBuildHiddenAndExternalPaths()
	{
		var root = CreateTempProject();
		try
		{
			var output = Path.Combine( root, "Code", "tailwand.generated.scss" );
			var outside = Path.Combine( Path.GetDirectoryName( root )!, Guid.NewGuid().ToString( "N" ), "Screen.razor" );

			Assert.IsFalse( TailBoxEditorWatcher.ShouldHandleChangedPath( root, output, Path.Combine( root, "bin", "Screen.razor" ) ) );
			Assert.IsFalse( TailBoxEditorWatcher.ShouldHandleChangedPath( root, output, Path.Combine( root, "obj", "Screen.razor" ) ) );
			Assert.IsFalse( TailBoxEditorWatcher.ShouldHandleChangedPath( root, output, Path.Combine( root, ".sbox", "Screen.razor" ) ) );
			Assert.IsFalse( TailBoxEditorWatcher.ShouldHandleChangedPath( root, output, outside ) );
			Assert.IsTrue( TailBoxEditorWatcher.ShouldHandleChangedPath( root, output, Path.Combine( root, "..generated", "Screen.razor" ) ) );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	[TestMethod]
	public void WatcherOptInRequiresConfigFile()
	{
		var root = CreateTempProject();
		try
		{
			Assert.IsFalse( TailBoxEditorWatcher.IsProjectOptedIn( root ) );
			TailBoxEditorProject.SaveDefaultConfig( root );
			Assert.IsTrue( TailBoxEditorWatcher.IsProjectOptedIn( root ) );
		}
		finally
		{
			DeleteTempProject( root );
		}
	}

	private static void AssertContainsAll( System.Collections.Generic.IEnumerable<string> values, params string[] expected )
	{
		var set = values.ToHashSet( StringComparer.Ordinal );
		foreach ( var item in expected )
		{
			Assert.IsTrue( set.Contains( item ), $"Expected '{item}' to be present." );
		}
	}

	private static void WriteFile( string root, string relativePath, string text )
	{
		var path = Path.Combine( root, relativePath.Replace( '/', Path.DirectorySeparatorChar ) );
		Directory.CreateDirectory( Path.GetDirectoryName( path )! );
		File.WriteAllText( path, text );
	}

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

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