Editor/Validation/Validator.cs
using System;
using System.Collections.Generic;
using Grains.RazorDesigner.Contracts;
using Grains.RazorDesigner.Document;
using Grains.RazorDesigner.Projection;

namespace Grains.RazorDesigner.Validation;

public sealed class Validator : IValidator
{
	private const string LogPrefix = "[Grains.RazorDesigner]";

	public IReadOnlyList<ValidationDiagnostic> Validate( IReadOnlyNode root )
	{
		var diagnostics = new List<ValidationDiagnostic>();

		// --- Dormant stubs (M5) -----------------------------------------------

		CheckSchemaUnsupported( diagnostics );

		CheckReservedKind( diagnostics );

		CheckBindingsNonempty( diagnostics );

		// --- Live rules (M3) --------------------------------------------------

		var seenClassNames = new Dictionary<string, Guid>( StringComparer.Ordinal );

		// Iterative DFS walk (avoids stack overflow on pathological deep trees).
		var stack = new Stack<IReadOnlyNode>();
		if ( root is not null )
			stack.Push( root );

		while ( stack.Count > 0 )
		{
			IReadOnlyNode node;
			try
			{
				node = stack.Pop();
			}
			catch ( Exception ex )
			{
				Log.Warning( $"{LogPrefix} Validator: exception popping node — {ex.Message}; skipping" );
				continue;
			}

			try
			{
				// Rule 1: leaf.has-children
				CheckLeafHasChildren( node, diagnostics );

				// Rule 2: slot.missing (only for SplitContainer)
				CheckSlotMissing( node, diagnostics );

				// Rule 3: classname.duplicate — accumulate into seenClassNames
				CheckClassNameDuplicate( node, seenClassNames, diagnostics );

				// Rule 7: pseudo.unsupported — reject state rules with out-of-set PseudoKind or bad :nth-child
				CheckStateRules( node, diagnostics );

				// Enqueue non-slot children (slot children are already in node.Slots).
				foreach ( var child in node.Children )
				{
					if ( child is not null )
						stack.Push( child );
				}

				foreach ( var slotList in node.Slots.Values )
				{
					foreach ( var slotChild in slotList )
					{
						if ( slotChild is not null )
							stack.Push( slotChild );
					}
				}
			}
			catch ( Exception ex )
			{
				// Malformed node (e.g. unrecognized Kind string) → diagnostic, never throw.
				Log.Warning( $"{LogPrefix} Validator: exception processing node {node?.Id} — {ex.Message}" );
				diagnostics.Add( new ValidationDiagnostic(
					node?.Id,
					DiagnosticSeverity.Error,
					DiagnosticCodes.SchemaUnsupported,
					$"Validator error on node {node?.Id}: {ex.Message}" ) );
			}
		}

		var count = diagnostics.Count;
		Log.Info( $"{LogPrefix} Validator: {count} diagnostic(s)" );
		return diagnostics;
	}

	// ─── Rule 1: leaf.has-children ───────────────────────────────────────────

	private static void CheckLeafHasChildren( IReadOnlyNode node, List<ValidationDiagnostic> out_ )
	{
		if ( node is null ) return;

		ControlType kind;
		if ( !Enum.TryParse( node.Kind, out kind ) )
			return; // unknown kind — let the malformed-node catch handle it if needed

		if ( ContractScanner.Table.Get( kind ).IsContainer ) return;  // containers are allowed children

		if ( node.Children.Count > 0 )
		{
			out_.Add( new ValidationDiagnostic(
				node.Id,
				DiagnosticSeverity.Error,
				DiagnosticCodes.LeafHasChildren,
				$"{kind} (class \"{node.ClassName}\") cannot have children but has {node.Children.Count}." ) );
		}
	}

	// ─── Rule 2: slot.missing ────────────────────────────────────────────────

	private static void CheckSlotMissing( IReadOnlyNode node, List<ValidationDiagnostic> out_ )
	{
		if ( node is null ) return;

		ControlType kind;
		if ( !Enum.TryParse( node.Kind, out kind ) ) return;
		if ( kind != ControlType.SplitContainer ) return;

		foreach ( var slotName in new[] { "left", "right" } )
		{
			IReadOnlyList<IReadOnlyNode> slotList;
			var hasSlot = node.Slots.TryGetValue( slotName, out slotList );
			if ( !hasSlot || slotList is null || slotList.Count == 0 )
			{
				out_.Add( new ValidationDiagnostic(
					node.Id,
					DiagnosticSeverity.Error,
					DiagnosticCodes.SlotMissing,
					$"SplitContainer (class \"{node.ClassName}\") is missing the \"{slotName}\" slot." ) );
			}
		}
	}

	// ─── Rule 3: classname.duplicate ─────────────────────────────────────────

	private static void CheckClassNameDuplicate(
		IReadOnlyNode node,
		Dictionary<string, Guid> seen,
		List<ValidationDiagnostic> out_ )
	{
		if ( node is null ) return;

		var name = node.ClassName;
		// Blank class names are valid (the node just has no class) — skip the duplicate check.
		if ( string.IsNullOrEmpty( name ) ) return;

		Guid firstId;
		if ( seen.TryGetValue( name, out firstId ) )
		{
			out_.Add( new ValidationDiagnostic(
				node.Id,
				DiagnosticSeverity.Error,
				DiagnosticCodes.ClassNameDuplicate,
				$"ClassName \"{name}\" is used by more than one node (first: {firstId}). SCSS rules will merge silently." ) );
		}
		else
		{
			seen[name] = node.Id;
		}
	}

	private static void CheckStateRules( IReadOnlyNode node, List<ValidationDiagnostic> out_ )
	{
		if ( node is null ) return;
		foreach ( var sr in node.StateRules )
		{
			if ( sr is null ) continue;

			if ( !Enum.IsDefined( typeof( PseudoKind ), sr.State ) )
			{
				out_.Add( new ValidationDiagnostic(
					node.Id,
					DiagnosticSeverity.Error,
					DiagnosticCodes.UnsupportedPseudoClass,
					$"Node \"{node.ClassName}\" has a state rule with unsupported pseudo-class value {(int)sr.State}." ) );
				continue;
			}

			if ( sr.State == PseudoKind.NthChild
				 && sr.NthChildMode == NthChildMode.Literal
				 && sr.NthChildArg < 1 )
			{
				out_.Add( new ValidationDiagnostic(
					node.Id,
					DiagnosticSeverity.Error,
					DiagnosticCodes.UnsupportedPseudoClass,
					$"Node \"{node.ClassName}\" has :nth-child({sr.NthChildArg}) — must be >= 1 (or use odd/even)." ) );
			}
		}
	}

	// ─── Dormant stubs (activate in M5) ──────────────────────────────────────

	private static void CheckSchemaUnsupported( List<ValidationDiagnostic> out_ )
	{
	}

	// activates in M5 when the IR envelope (themeRef / localization) exists.
	private static void CheckReservedKind( List<ValidationDiagnostic> out_ )
	{
	}

	// activates in M5 when the bindings field exists on documents / nodes.
	private static void CheckBindingsNonempty( List<ValidationDiagnostic> out_ )
	{
	}
}