UnitTests/SyncToolFlowCanonicalizerTests.cs
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Sandbox;

[TestClass]
public class SyncToolFlowCanonicalizerTests
{
	// ──────────────────────────────────────────────────────
	// The cases the user reported: legacy onFail vs canonical
	// routes.{true,false} must compare as semantically identical
	// after canonicalization, regardless of which side authored
	// which shape.
	// ──────────────────────────────────────────────────────

	[TestMethod]
	public void LegacyOnFailMapsToCanonicalRoutesFalseReject()
	{
		var legacy = ParseStep( @"{
			""id"": ""guard"",
			""type"": ""condition"",
			""check"": { ""field"": ""x"", ""op"": "">"", ""value"": 0 },
			""onFail"": { ""status"": 400, ""error"": ""BAD"", ""message"": ""bad"" }
		}" );

		var canonical = ParseStep( @"{
			""id"": ""guard"",
			""type"": ""condition"",
			""check"": { ""field"": ""x"", ""op"": "">"", ""value"": 0 },
			""routes"": {
				""false"": { ""action"": ""reject"", ""status"": 400, ""error"": ""BAD"", ""message"": ""bad"" }
			}
		}" );

		AssertSemanticallyEqual( legacy, canonical );
	}

	[TestMethod]
	public void LegacyOnFailStringSkipMapsToRoutesFalseSkip()
	{
		var legacy = ParseStep( @"{
			""id"": ""guard"",
			""type"": ""condition"",
			""check"": { ""field"": ""x"", ""op"": ""=="", ""value"": true },
			""onFail"": ""skip""
		}" );

		var canonical = ParseStep( @"{
			""id"": ""guard"",
			""type"": ""condition"",
			""check"": { ""field"": ""x"", ""op"": ""=="", ""value"": true },
			""routes"": {
				""false"": { ""action"": ""skip"", ""count"": 1 }
			}
		}" );

		AssertSemanticallyEqual( legacy, canonical );
	}

	[TestMethod]
	public void OnTrueOnFalseShorthandMapsToRoutes()
	{
		var legacy = ParseStep( @"{
			""id"": ""guard"",
			""type"": ""condition"",
			""onTrue"": { ""action"": ""continue"" },
			""onFalse"": { ""action"": ""reject"", ""status"": 403 }
		}" );

		var canonical = ParseStep( @"{
			""id"": ""guard"",
			""type"": ""condition"",
			""routes"": {
				""true"": { ""action"": ""continue"" },
				""false"": { ""action"": ""reject"", ""status"": 403 }
			}
		}" );

		AssertSemanticallyEqual( legacy, canonical );
	}

	[TestMethod]
	public void ConditionStepWithoutAnyRouteFieldsGetsDefaultRoutes()
	{
		// A bare condition step with no route hints normalizes to the implicit
		// continue/reject pair that the runtime applies anyway.
		var bare = ParseStep( @"{
			""id"": ""guard"",
			""type"": ""condition"",
			""check"": { ""field"": ""x"", ""op"": "">"", ""value"": 0 }
		}" );

		var withDefaults = ParseStep( @"{
			""id"": ""guard"",
			""type"": ""condition"",
			""check"": { ""field"": ""x"", ""op"": "">"", ""value"": 0 },
			""routes"": {
				""true"": { ""action"": ""continue"" },
				""false"": { ""action"": ""reject"" }
			}
		}" );

		AssertSemanticallyEqual( bare, withDefaults );
	}

	[TestMethod]
	public void NonConditionStepsWithoutRouteFieldsArePassedThrough()
	{
		var step = ParseStep( @"{
			""id"": ""write"",
			""type"": ""write"",
			""collection"": ""players"",
			""key"": ""{{steamId}}""
		}" );

		var normalized = (Dictionary<string, object>)SyncToolFlowCanonicalizer.NormalizeStepRoutes( step );
		Assert.IsFalse( normalized.ContainsKey( "routes" ) );
		Assert.AreEqual( "write", normalized["type"] );
	}

	[TestMethod]
	public void RealValueDifferencesStillSurface()
	{
		// Same logical shape, different status code — must NOT compare as equal.
		var a = ParseStep( @"{
			""id"": ""guard"",
			""type"": ""condition"",
			""onFail"": { ""status"": 400, ""error"": ""BAD"" }
		}" );

		var b = ParseStep( @"{
			""id"": ""guard"",
			""type"": ""condition"",
			""onFail"": { ""status"": 403, ""error"": ""BAD"" }
		}" );

		var aJson = SerializeNormalized( a );
		var bJson = SerializeNormalized( b );
		Assert.AreNotEqual( aJson, bJson );
	}

	[TestMethod]
	public void NestedStepsAreNormalizedRecursively()
	{
		var legacy = ParseStep( @"{
			""id"": ""outer"",
			""type"": ""group"",
			""steps"": [
				{
					""id"": ""inner"",
					""type"": ""condition"",
					""onFail"": { ""status"": 409, ""error"": ""CONFLICT"" }
				}
			]
		}" );

		var canonical = ParseStep( @"{
			""id"": ""outer"",
			""type"": ""group"",
			""steps"": [
				{
					""id"": ""inner"",
					""type"": ""condition"",
					""routes"": {
						""false"": { ""action"": ""reject"", ""status"": 409, ""error"": ""CONFLICT"" }
					}
				}
			]
		}" );

		AssertSemanticallyEqual( legacy, canonical );
	}

	[TestMethod]
	public void NormalizeStepsAcceptsJsonElementArray()
	{
		var doc = JsonDocument.Parse( @"[
			{ ""id"": ""guard"", ""type"": ""condition"", ""onFail"": { ""status"": 400 } }
		]" );

		var result = SyncToolFlowCanonicalizer.NormalizeSteps( doc.RootElement );

		Assert.IsNotNull( result );
		Assert.AreEqual( 1, result.Count );
		var step = (Dictionary<string, object>)result[0];
		Assert.IsTrue( step.ContainsKey( "routes" ) );
		Assert.IsFalse( step.ContainsKey( "onFail" ) );
	}

	[TestMethod]
	public void NormalizeStepsReturnsNullForNonArrayInput()
	{
		Assert.IsNull( SyncToolFlowCanonicalizer.NormalizeSteps( null ) );
		Assert.IsNull( SyncToolFlowCanonicalizer.NormalizeSteps( "not-an-array" ) );
		Assert.IsNull( SyncToolFlowCanonicalizer.NormalizeSteps( 42 ) );
	}

	// ──────────────────────────────────────────────────────
	// Helpers — parse JSON into the dict shape the canonicalizer
	// accepts, then check post-normalization equivalence by
	// comparing canonical-form JSON.
	// ──────────────────────────────────────────────────────

	private static Dictionary<string, object> ParseStep( string json )
	{
		using var doc = JsonDocument.Parse( json );
		return ToDict( doc.RootElement );
	}

	private static Dictionary<string, object> ToDict( JsonElement el )
	{
		var dict = new Dictionary<string, object>();
		foreach ( var prop in el.EnumerateObject() )
			dict[prop.Name] = ToObject( prop.Value );
		return dict;
	}

	private static object ToObject( JsonElement el )
	{
		switch ( el.ValueKind )
		{
			case JsonValueKind.Object:
				return ToDict( el );
			case JsonValueKind.Array:
				return el.EnumerateArray().Select( ToObject ).ToList();
			case JsonValueKind.String:
				return el.GetString();
			case JsonValueKind.Number:
				return el.TryGetInt64( out var l ) ? (object)l : el.GetDouble();
			case JsonValueKind.True:
				return true;
			case JsonValueKind.False:
				return false;
			default:
				return null;
		}
	}

	private static string SerializeNormalized( Dictionary<string, object> step )
	{
		var normalized = SyncToolFlowCanonicalizer.NormalizeStepRoutes( step );
		// Run through the YAML renderer's underlying sort so two dicts with
		// the same content but different insertion order serialize identically.
		var json = JsonSerializer.Serialize( normalized );
		return SyncToolYamlRenderer.RenderFromJson( json );
	}

	private static void AssertSemanticallyEqual( Dictionary<string, object> a, Dictionary<string, object> b )
	{
		var aOut = SerializeNormalized( a );
		var bOut = SerializeNormalized( b );
		Assert.AreEqual( aOut, bOut, $"Expected canonicalized YAML to match.\n--- A ---\n{aOut}\n--- B ---\n{bOut}" );
	}
}